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 34a0db0add
41 files changed
+4121

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
# Rules for coding agents building a Cletus plugin
You are building a plugin for Cletus. Every plugin is reviewed line by line
before Eidonix publishes it, and a plugin that breaks these rules is sent
back. Follow them exactly. When a rule and a user's request conflict, stop
and tell the user which rule, and why, before writing code.
## Before you write code
1. Read `docs/specification.md`, `docs/host-api.md` and
`docs/style-guide.md` in full. Read the matching section of
`docs/patterns.md` for the kind of plugin you are building.
2. Write `docs/plan.md` in the plugin: what it does, its screens, its chat
tools, its services, every permission with one sentence on why, and every
dependency with one sentence on why. Show it to the user and get their
agreement before building.
3. If the plugin needs something the host API does not offer, say so and
stop. Do not work around the host.
## The contract
- **Use only the host methods in `docs/host-api.md`.** Never guess a method
name or a parameter. If a call is refused, the plugin is missing a
permission or the method does not exist. Never retry it another way.
- **Declare the least permissions** that make the plugin work. Every
permission in `plugin.json` must be used, and every use must be declared.
- **Files:** read and write only paths the host gave you (`host.files.resolve`,
dialogs, drops, `open()` payloads), your own data folder, and the working
folder when `host.files.can_write` allows it. Direct access anywhere else
needs `files.computer` and a line in `docs/plan.md` naming the folders.
- **Network:** connect only to the `net:<domain>` hosts you declared, or to
addresses the user typed into your plugin (`net.configured`). Never
hard-code an IP address. Never send data to a server the description does
not name.
- **Secrets:** never in code, files, logs or plugin storage. Passwords the
user gives you go through `host.secrets.*` and nowhere else.
- **Programs:** start none without `processes`, and then only the programs
`docs/plan.md` names, with arguments built from a fixed list, never by
passing user text to a shell.
- **AI:** use the organization's models only through `host.llm.*`. Never
call a model provider directly, never ask the user for an API key.
## Forbidden outright
- cgo, prebuilt binaries or libraries, and anything else that is not built
from source in the repository (C and C++ libraries may be compiled to
WebAssembly from source and run with wazero; see `docs/patterns.md`).
- Code downloaded, generated or evaluated at run time: `eval`, `new
Function`, Go `plugin`, scripts fetched from anywhere.
- Obfuscation or minified sources in the repository; `unsafe`, `reflect` or
`syscall` without a comment that a reviewer will accept.
- Behaviour that changes by date, by machine, by user, or under test.
- Reading the user's Cletus settings, keys, chat history, memory, vault
files, or other plugins' data.
- Network requests from the screen (they are blocked; publish data through
the worker).
- Logging file contents, prompts, model replies, passwords or personal data.
- Telemetry or analytics of any kind.
## Building
- One plugin per repository. It builds alone from the repository, with no
network: `go mod vendor` and commit `vendor/`.
- Go: the version in `go.mod` is the one the SDK names. `CGO_ENABLED=0`.
`gofmt`, `go vet` and `staticcheck` clean.
- A new dependency needs a reason in `docs/plan.md`, a licence that allows
distribution, and no known serious vulnerability. Prefer the standard
library. Fewer dependencies review faster.
- Screens wait for the screen toolchain (a later SDK release). Until then,
build no screen: deliver the feature as chat tools.
## Code
- Simple code that does one thing. No abstraction until a second real use
exists. No hidden fallbacks: when something fails, fail with the reason
and what the user (or the AI) can do next.
- Every handler honours cancellation (`ctx`). Every long job reports
progress and can be cancelled. Answer `plugin.busy` truthfully.
- Data you write must stay readable by every earlier published version's
successor: users choose versions and can go back to any of them. Version
your file formats; never rewrite a user's file in a format an older version
can't open without asking.
- Svelte: an `$effect` never reads and writes the same `$state`.
- Error messages are plain sentences for a person, not codes or stack
traces (see `docs/style-guide.md`).
## Tests
- Every chat tool, service method and `ui.*` method has a test. Every bug
fix adds the test that would have caught it.
- Use synthetic fixtures you made. Never commit a customer's file.
- `go test -run X` that matches nothing still passes. Confirm you see
`--- PASS` for the tests you meant.
## Before you say you're done
1. `cletus-plugin check` passes with no warnings you have not explained in
`docs/plan.md`.
2. `version` in `plugin.json` is raised and `CHANGELOG.md` has an entry
written for users.
3. `docs/plan.md` matches what you built: permissions, dependencies,
services, tools.
4. Walk `docs/review-checklist.md` item by item and fix anything that fails.
5. Tell the user plainly what was built, what was tested, and anything you
could not do. Never say something works if you didn't see it work.
Do not push tags or submit to the store unless the user asks you to.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Cletus plugin kit
Copyright 2026 Eidonix
Licensed under the Apache License, Version 2.0 (see LICENSE).
Cletus and Eidonix are trademarks of Eidonix and are not licensed by it.
+73
View File
@@ -0,0 +1,73 @@
# Cletus plugins
Build plugins for Cletus, the desktop AI assistant for engineering and
manufacturing teams, and publish them in the Eidonix plugin store.
## Status
- **Ready now:** the specification, the worker SDK (`sdk/pluginsdk`) and the
`cletus-plugin` tool, which creates a plugin, checks it the way the store
does and runs it under a test host. You can build and test a worker and
its chat tools today.
- **Later releases:**
- screens;
- more host services (AI models, vision, speech, camera and microphone,
saved connections, a secret store);
- loading your development build into Cletus itself;
- store submissions.
## What a plugin is
A plugin is a Go program (the **worker**) that adds chat tools Cletus's AI
can use, and services other plugins can call. Through the host it can:
- open and save files the user chooses;
- read and write the user's shared folders;
- start background Cletus runs over the user's files;
- connect to machines, databases and services the user sets up, or to
servers it names.
## Start here
If you work with a coding agent (Claude Code, Codex or similar), point it at
this repository and tell it what you want to build. `AGENTS.md` holds the
rules it must follow, and it reads the rest from there.
If you work by hand, read in this order:
1. `docs/specification.md`: the package, `plugin.json`, permissions, the
worker and services.
2. `docs/host-api.md`: every host method.
3. `docs/style-guide.md`: how Cletus plugins read and behave.
4. `docs/patterns.md`: how to build common kinds of plugin.
5. `docs/submission.md` and `docs/review-checklist.md`: how publishing will
work.
## Tools
Needs Go 1.24 or later.
```sh
go install git.eidonix.com/eidonix/cletus-plugins/sdk/cmd/cletus-plugin@latest
cletus-plugin new acme.gauge-check # a working plugin from the template
cd gauge-check
cletus-plugin check # the store's automated checks
cletus-plugin run -files ./testdata tools # list the chat tools
cletus-plugin run -files ./testdata invoke gauge_check_hello '{"name":"Ada"}'
```
`run` starts your worker under a test host that answers host calls the way
Cletus does, enforces your declared permissions, and prints every host call
your worker makes.
## Publishing
Plugins reach users only through the Eidonix plugin store, and Cletus
installs nothing else. You'll submit source code; Eidonix builds it, checks
it, reviews it and signs it. See `docs/submission.md`.
## Licence
Apache License 2.0 (`LICENSE`). The names Cletus and Eidonix are not
licensed.
+82
View File
@@ -0,0 +1,82 @@
# Host API reference (version 1)
Every method a worker may call, with the permission it needs. A worker calls
them only while handling a request (`parent_id`). Errors return
`{"error": {"message"}}` as a plain sentence; show it or pass it on, don't
parse it.
Only the methods listed here exist. More host services (the organization's
AI models, vision, speech, camera and microphone, saved connections and a
secret store) are planned and will be added to this reference when they are
released. Don't call a method that isn't listed.
## Always available
| Method | Params | Result |
| --- | --- | --- |
| `host.auth.has_capability` | `{capability}` | `{granted}`: whether the signed-in user has a Cletus feature |
| `host.data.publish` | `{path, contentType, ttl}` | `{token, url, expiresAt}`: a link your screen can fetch for an immutable file in your data folder; `ttl` is 1 to 3600 seconds, default 300 |
| `host.data.revoke` | `{token}` | releases a link |
| `host.data.leases` | `{path}` | the number of live links to a file |
| `host.data.remove` | `{path}` | deletes a file in your data folder unless a link holds it (never check, then delete) |
| `host.event` | `{name, args}` | sends an event to your screen; `name` starts with `<your id>:` |
| `host.screen.open` | `{id, payload}` | opens your screen, or another plugin's screen that it offers as `screen.open` and that you `use` |
| `host.dialog.open` | `{Title, DefaultDirectory, Filters: [{DisplayName, Pattern}]}` | the chosen path, or `""` |
| `host.dialog.folder` | as `open` | the chosen folder, or `""` |
| `host.dialog.save` | `{Title, DefaultDirectory, DefaultFilename, Filters}` | the chosen path, or `""` |
| `host.dialog.message` | `{Type, Title, Message, Buttons}` | the button pressed |
| `host.plugin.call` | `{target, service, method, args}` | the provider's result; see "Services" in the specification |
Dialogs and screens are refused in background runs, where nobody is
watching.
## `files`
| Method | Params | Result |
| --- | --- | --- |
| `host.files.resolve` | `{path}` | the real path (a string) of a file the user opened, dropped or picked, or one in the working folder; an error otherwise. Resolve every path before reading it. |
| `host.files.can_write` | `{path}` | `true` or `false` under the user's current write rules (read-only mode, background-run limits) |
## `workspace.read`, `workspace.write`
Shared folders. Keys are full: `ws:<uuid>/files/<path>`. Vault (end-to-end
encrypted) folders and anything outside `files/` are never visible.
| Method | Permission | Params | Result |
| --- | --- | --- | --- |
| `host.workspace.folders` | read | `{}` | `[{id, name, role, personal?}]`; `role` is `owner`, `member` or `viewer` |
| `host.workspace.list` | read | `{workspace, prefix}` | `[{key, size, etag, modified}]`, at most 1000; `workspace` is an id or an exact folder name |
| `host.workspace.fetch` | read | `{key}` | `{path, etag}`: a local cached copy (unchanged files aren't downloaded again) |
| `host.workspace.read` | read | `{key}` | `{data, etag, size}`: the bytes as base64, in memory, at most 2 MiB |
| `host.workspace.put` | write | `{workspace, key, path}` or `{workspace, key, data}` | `{key, size, etag}`; `key` is relative to `files/`; owner or member only |
| `host.workspace.delete` | write | `{key}` | `{key}`; deleting a missing key succeeds |
| `host.workspace.create` | write | `{name}` | `{id, name, role}`: a new folder the user owns |
## `agents`
| Method | Params | Result |
| --- | --- | --- |
| `host.agent.start` | `{playbook_slug?, prompt, paths, label}` | `{run_id}` |
| `host.agent.status` | `{run_id}` | `{run_id, state, steps, last_message, error, label, report}` |
| `host.agent.cancel` | `{run_id}` | the status after the cancel |
A run is a separate Cletus conversation over files the user chose (`paths`,
at most 64, each passing `host.files.resolve`). Give a playbook, a prompt,
or both. It can use your plugin's tools and writes only inside the folders
in `paths`. `state` is `queued`, `running`, `done`, `failed` or `cancelled`.
Events `<id>:agent-progress` and `<id>:agent-done` carry the status; key
your screen's state by `run_id`. At most 4 runs per plugin are queued or
running.
## `identity`
| Method | Params | Result |
| --- | --- | --- |
| `host.auth.whoami` | `{}` | `{key_id, name, org, org_id}`. `key_id` is the person's identity; names are unique only within an organization. |
## Screen SDK
Arrives with the screen toolchain in a later SDK release. It will offer
calls to your own worker, your events, navigation, confirm dialogs,
storage, clipboard writes, the theme, and the host's components (icons,
side panel, status bar, context menu, the standard 3D viewport look).
+73
View File
@@ -0,0 +1,73 @@
# Patterns
How to build common kinds of plugin on this contract. Each pattern says what
works today and what waits for a later release.
## Chat tools over your own files and formats
Works today. The smallest useful plugin and the fastest to review.
- Tools that read a format Cletus doesn't understand (an instrument export, a
machine log, a proprietary report) and return a short, typed summary.
- `files` permission; every path through `host.files.resolve`.
- Large results are written to a file next to the input (after
`host.files.can_write`) and the tool returns its path and a summary.
## Long computations and batch jobs
Works today.
- A tool or service that starts a job returns a job id at once. Separate
tools report `status`, return `results` and `cancel`.
- Job state lives in the data folder, so a restarted worker can report it.
- `plugin.busy` answers `true` while a job runs, so Cletus doesn't stop the
worker for an update.
- Results are written in the folder the user chose, never elsewhere.
## Shared reference data
Works today.
- Reference data a team shares (standards, tables, templates) lives in a
shared folder the user picks (`workspace.read`), never inside the plugin.
- The worker fetches it with `host.workspace.fetch`, which caches unchanged
files.
- Writing results back for the team needs `workspace.write`.
## Reading documents in the background
Works today.
- `agents`: `host.agent.start` runs Cletus over the files the user chose,
with a playbook or a prompt. The run can call your plugin's own tools.
- Poll `host.agent.status`, or wait for `<id>:agent-done`.
## Connecting machines, databases and services
Works today, with direct network access.
- `net.configured` for addresses the user enters (controllers, databases,
instruments), or `net:<domain>` for a fixed service.
- Pure-Go drivers only.
- Read-only by default. Every write is a separate tool marked `mutating` and
`requires_approval`, and `docs/plan.md` says what it changes.
- Until the secret store ships, ask for passwords when needed and keep them
in memory only.
## Libraries written in C or C++
Works today.
- Compile them to WebAssembly from source in your repository and run them
with [wazero](https://wazero.io) inside the worker.
- The build stays pure Go, and review sees the library source.
## With a later release
- **Screens:** 2D and 3D screens in a sandboxed frame. The worker computes
and publishes data (`host.data.publish`); the screen draws it.
- **The organization's AI, vision and speech:** summaries, image reading,
transcription.
- **Camera and microphone** through the host.
- **Saved connections and a secret store.**
- **Services of Eidonix plugins**, such as opening a file in CAD Viewer.
+55
View File
@@ -0,0 +1,55 @@
# Review checklist
The store's reviewers walk this list for every submission. Walk it yourself
first. Anything marked **(auto)** is also checked by `cletus-plugin check`.
## Identity and listing
- [ ] `id` starts with your publisher id; `version` is new and matches the tag. **(auto)**
- [ ] Name, description and guide are accurate and plain. No
claims about Eidonix or other companies, no imitation of their screens.
- [ ] `CHANGELOG.md` describes this version for users.
## Permissions
- [ ] Every declared permission is used, and every use is declared. **(auto, for host calls)**
- [ ] `docs/plan.md` gives a reason for each permission that a user would accept.
- [ ] `files.computer`, `net:*`, `net.configured` and `processes`: the code
does only what `docs/plan.md` says (folders, hosts, addresses typed by
the user, named programs).
- [ ] Data leaves the machine only where the description says so.
## Code
- [ ] Builds from source with no network, `CGO_ENABLED=0`, no prebuilt
binaries, vendored dependencies. **(auto)**
- [ ] No code loaded, generated or evaluated at run time. **(auto, flags for review)**
- [ ] No obfuscation; any `unsafe`, `reflect`, `syscall`, `os/exec`, `net`
or direct file access is explained and matches the permissions. **(auto flags)**
- [ ] No behaviour that depends on the date, machine, user or test mode.
- [ ] Paths come from the host or the data folder; nothing reads Cletus's
settings, keys, history, vaults or other plugins' data.
- [ ] Secrets only through `host.secrets`; none in code, files or logs. **(auto scan)**
- [ ] No telemetry. Logs carry no user data.
- [ ] Dependencies: licences allow distribution; no known serious
vulnerability. **(auto)**
- [ ] Handlers honour cancellation; long jobs have progress and cancel;
`plugin.busy` is truthful.
- [ ] Files written stay readable across downgrades, or are refused with a
plain message.
## Behaviour (the recorded run)
Reviewers run the plugin under a recording host that logs every host call,
file opened, connection attempted and program started.
- [ ] Everything in the recording matches the permissions and `docs/plan.md`.
- [ ] Chat tools do one thing each, are marked `mutating` correctly, and
fail with a reason and a next step.
- [ ] A screen, if any, follows `style-guide.md` in all three themes: tokens only,
density, no layout shift, Esc, plain errors.
## Tests
- [ ] Go tests cover every tool, service method and `ui.*` method, and pass. **(auto)**
- [ ] Fixtures are synthetic.
+283
View File
@@ -0,0 +1,283 @@
# Cletus plugin specification
Draft 1, 2026-11-10. Host API version 2.
## Parts of a plugin
| Part | Language | Runs | Required |
| --- | --- | --- | --- |
| Worker | Go | as its own process, started and stopped by Cletus | yes |
| Chat tools | Go, in the worker | when Cletus's AI calls them, in chat or background runs | no |
| Screen | TypeScript (Svelte 5 supported) | in a sandboxed frame inside the Cletus window | no |
| Services | Go, in the worker | when another plugin calls them through the host | no |
The worker does the work. The screen shows it. The host (Cletus) sits
between them and everything else: the user's files and shared folders,
background agent runs, other plugins. The plugin never holds the
user's Cletus key or any organization credential.
## The repository
```
plugin.json identity, permissions, tools, services
CHANGELOG.md one entry per version, written for users
go.mod, go.sum the worker module (CGO_ENABLED=0)
vendor/ go mod vendor output; the build has no network
cmd/worker/ the worker's main package
internal/ the rest of the worker
testdata/ synthetic files for tests and `cletus-plugin run`
docs/plan.md purpose, permissions and dependencies, each with a reason
docs/guide.md the user guide, shown on the store listing
listing/ listing.json for the store
```
Eidonix builds the worker for macOS (arm64) and Windows (amd64) with the Go
version the SDK pins, with `CGO_ENABLED=0`. `cletus-plugin check` builds the
same two targets locally.
## plugin.json
```json
{
"id": "acme.gauge-check",
"name": "Gauge Check",
"description": "Check measured gauges against the drawing's tolerances.",
"version": "1.2.0",
"min_app_version": "0.5.0",
"host_api": 2,
"color": "teal",
"permissions": ["files", "workspace.read"],
"file_types": [".gauge"],
"opens_folders": false,
"tools": [
{"name": "gauge_check_run", "mutating": false},
{"name": "gauge_check_save_report", "mutating": true, "requires_approval": true}
],
"provides": {},
"uses": ["cad-viewer:cad.open"],
"dependencies": []
}
```
| Field | Rules |
| --- | --- |
| `id` | `<publisher>.<name>`. Eidonix assigns your publisher id. `<name>`: lowercase letters, digits, hyphens. Never changes. |
| `name`, `description` | Shown in the store and the app. Plain words. No claims about Eidonix or other companies. |
| `version` | Semantic version, raised on every submission. Never reused. |
| `min_app_version` | The oldest Cletus with every host method you call (`host-api.md` lists each method's version). |
| `host_api` | `2`. |
| `color` | One of `red orange amber yellow green teal blue indigo violet pink grey`. The app maps it to each theme. |
| `permissions` | See "Permissions". Undeclared host methods are refused. |
| `file_types` | Extensions your screen opens (with the screen toolchain) ("Open in …"). Claim your own formats. Common formats (`.xlsx`, `.pdf`, `.step`) are allowed when the plugin really works on them; the user picks which plugin opens a file. |
| `opens_folders` | `true` if your screen opens a whole folder. |
| `tools` | Every chat tool. `mutating: true` if it writes anything anywhere. `requires_approval: true` makes Cletus ask the user each time. Must match what the worker reports. |
| `ui`, `ui_host_api` | The screen and the SDK modules it uses. Defined with the screen toolchain; leave them out until then. |
| `provides`, `uses` | Services offered to other plugins, and theirs you call. See "Services". |
| `dependencies` | Plugins that must be installed for yours to work (for example `cad-viewer`). The user installs them; nothing is bundled. |
## Permissions
Shown to the organization's admin when they turn your plugin on, and to the
user at install, in the plain words below. An update that adds a permission
is reviewed again and must be approved again by each organization.
**App** means Cletus refuses calls you didn't declare. **Review** means the
worker could technically do it, so review checks that the code does only
what `docs/plan.md` says.
| Permission | Plain words | Grants | Enforced by |
| --- | --- | --- | --- |
| (always) | | Chat tools, own data folder, own screen, events, dialogs, published data links | app |
| `files` | Open and save files you choose | `host.files.*`: files the user opened, dropped or picked, and the working folder within its write rules | app + review |
| `files.computer` | Read and write other files on this computer | Direct access to the folders `docs/plan.md` names | review |
| `workspace.read` | Read files in shared folders you're a member of | `host.workspace.folders / list / fetch / read` | app |
| `workspace.write` | Add, change and delete files in shared folders | `host.workspace.put / delete / create / rename` | app |
| `agents` | Run Cletus in the background | `host.agent.*` | app |
| `chat.handoff` | Send work to chat | the screen's `handoffToChat` | app |
| `identity` | See your name and organization | `host.auth.whoami` | app |
| `net:<domain>` | Connect to <domain> | Direct connections from the worker to that host and its subdomains; at most 16 | review |
| `net.configured` | Connect to devices and servers you set up in this plugin | Direct connections to addresses the user enters in the plugin (controllers, databases, instruments) | review |
| `processes` | Run programs on this computer | Starting the programs `docs/plan.md` names | review |
Permissions for the planned host services (AI models, vision, speech,
camera and microphone, saved connections, a secret store) will be added with
them. Until then, a plugin that needs a password asks the user each time and
keeps it in memory only.
**Never granted**, with or without review: the user's Cletus key or session;
organization or provider credentials; vault keys or vault contents; other
plugins' data; Cletus's settings and stored secrets; the user's chat
history and memory; the commands of Cletus Code or System Administration;
an organization's managed libraries.
## The worker
Cletus starts the worker on first use and stops it when the plugin is
disabled, updated or uninstalled, or the user signs out. It restarts a
crashed worker on the next call. The worker gets:
- `CLETUS_PLUGIN_ID`: your plugin id;
- `CLETUS_PLUGIN_DATA`: its **data folder**, per signed-in account, kept
across updates and uninstalls, and also its working directory. Settings,
caches, history and job state go there (`pluginsdk.DataDir()`);
- a few standard variables (`HOME`, `PATH`, temp folders, locale). Don't
rely on any other variable;
- standard input and output for messages, standard error for its log
(`worker.log` in the data folder; never log user data).
The SDK's `pluginsdk.Serve` implements the protocol below. You register
handlers.
```go
func main() {
err := pluginsdk.Serve(pluginsdk.Handlers{
Tools: []pluginsdk.Tool{countLines}, // chat tools
UI: map[string]pluginsdk.Func{"check": check}, // answers ui.check
Services: map[string]pluginsdk.ServiceFunc{"gauge.check": gauge}, // answers svc.gauge.check
Busy: jobs.Running,
})
if err != nil {
os.Exit(1)
}
}
var countLines = pluginsdk.Tool{
Name: "gauge_check_count_lines",
Description: "Count the lines in a text file. Returns the count.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}, "required": []string{"path"}},
Run: func(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) pluginsdk.ToolResult {
var args struct{ Path string `json:"path"` }
if err := call.Decode(&args); err != nil {
return pluginsdk.Fail("%v", err)
}
path, err := h.ResolvePath(ctx, args.Path) // host.files.resolve
if err != nil {
return pluginsdk.Fail("%v", err)
}
// … read path …
return pluginsdk.Text("%s has %d lines.", path, n)
},
}
```
`h.Call(ctx, method, params, &out)` makes any call in `host-api.md`;
`ResolvePath`, `CanWrite`, `Event` and `Publish` are typed shortcuts.
`pluginsdk.NewTestHost` answers host calls in unit tests. The template that
`cletus-plugin new` writes shows all of this working.
### Protocol
One JSON object per line in each direction, at most 64 MiB per line.
```json
{"id": "7", "method": "tools.invoke", "params": {}}
{"id": "7", "result": {}}
{"id": "7", "error": {"message": "The drawing has no tolerance table."}}
```
A host call made while handling request `7` carries `"parent_id": "7"`.
Cancellation is `{"method": "$/cancel", "params": "7"}`: stop, and reply
with an error. Stop promptly: a worker that ignores cancellation may be
stopped.
| Method the worker answers | Params | Result |
| --- | --- | --- |
| `plugin.handshake` | none | `{"api_version": 1}` |
| `plugin.shutdown` | none | `{"ok": true}`, then exit |
| `plugin.busy` | none | `true` while work must not be interrupted; Cletus then won't update or stop the plugin |
| `tools.list` | none | `[{"def": <function definition>, "mutating": bool, "requires_approval": bool}]` |
| `tools.invoke` | `{"name", "args", "working_dir", "edit_mode"}` (`args` is JSON text) | `{"Text": "…"}` or `{"Error": "…"}`; `"Images": [base64]` optional |
| `ui.<name>` | the screen's arguments, as a JSON array | any JSON; answer within 60 seconds (start longer work as a job and report it with events) |
| `svc.<service>.<method>` | `{"caller", "mutating", "args"}` | must match the declared `result` schema |
### Chat tools
The AI reads your tool definitions and results, so write them for it:
- One tool does one thing. Name it `<plugin short name>_<verb>`.
- The description says what it does, when to use it, and what it returns.
- Results are short and factual. A failure says why and what to call next.
Never try a different method silently.
- When `edit_mode` is false, the user has Cletus read-only: a mutating tool
asks `host.files.can_write` for each path and refuses if the answer is no.
The same tools run in background agent runs, where nobody watches. Don't open
dialogs from a tool.
## The screen
**The screen toolchain (the `@cletus/plugin-sdk` package, its build and a
preview host) arrives in a later SDK release.** Until then, build the worker
and chat tools; a plugin with tools only is complete and publishable. Design
your screen with the rules below so it drops in when the toolchain ships.
- The screen runs in a sandboxed frame with no network access. It cannot see
the Cletus window or other plugins.
- It calls only its own worker: `call('check', arg)` reaches the worker's
`ui.check` method, with the arguments as a JSON array.
- Events from the worker (`host.event`) reach the screen only under your own
`<id>:` prefix.
- Large data (meshes, images, tables) is published by the worker
(`host.data.publish`) and fetched by the screen from the returned link.
Every other request is blocked.
- Settings: up to 512 KiB, stored by the host.
- WebGL, WebGPU, Web Workers, canvas, clipboard writes and downloads of your
published data work. The camera and microphone don't.
- Cletus draws the menu bar and the ← Chat button above the screen.
- Colours, density and layout follow `style-guide.md`.
## Services
Plugins share no code. They call each other's declared services through the
host, which checks both sides.
```json
"provides": {
"gauge": {"methods": {"check": {
"description": "Check a gauge file against its drawing.",
"params": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"], "additionalProperties": false},
"result": {"type": "object"},
"mutating": false,
"long": false
}}},
"screen": {"methods": {"open": {"params": {"type": "object"}, "result": {"type": "null"}}}}
},
"uses": ["cad-viewer:cad.open", "acme.fixtures:fixture.*"]
```
- Services are lowercase and may be dotted (`gauge.batch`). Methods are
`[a-z][a-z0-9_]*`. A use is `<plugin>:<service>.<method>` or
`<plugin>:<service>.*`.
- `params` and `result` are JSON schemas (draft 2020-12 or draft-07, at most
16 KiB, no remote `$ref`). The host checks arguments and results against
them.
- `screen.open` is reserved. Provide it if other plugins may open your screen.
Its `params` is the payload your `open()` receives.
- Calls are limited to 5 per second after a burst of 20 per pair, 1 MiB of
arguments, and 2 minutes (15 minutes for methods marked `long`).
- A mutating method linked to a tool that needs approval is refused when
called from another plugin, because the user can't be asked.
- Services between your own plugins work the same way. Services of Eidonix
plugins are not open to publishers yet; they will be listed in
`host-api.md` when they are.
## Versions and data
- Users choose when to update and can go back to any earlier version. Keep
every file you write readable after a downgrade, or refuse it with a plain
message naming the version that wrote it.
- Uninstalling keeps the data folder. Reinstalling picks it up.
- `min_app_version` protects users on older Cletus releases. Raise it
whenever you use a newer host method.
## Limits
| Limit | Value |
| --- | --- |
| Message | 64 MiB per line |
| Service call | 1 MiB arguments; 2 minutes, or 15 if `long` |
| Background agent runs | 4 queued or running per plugin |
| Published data link | 1 hour at most |
| Screen storage | 512 KiB |
| `workspace.read` | 2 MiB per call (use `fetch` for larger files) |
| Package | 768 MiB download, 3 GiB unpacked, 20,000 files |
+134
View File
@@ -0,0 +1,134 @@
# Style guide
Cletus is a professional tool for engineers, analysts and shop-floor teams.
A plugin should feel like part of it: calm, dense, exact, and never in the
way. Review checks this guide.
## 1. Principles
- **It just works.** Values that can be computed are computed. Nothing
waits for a button press that could have been automatic.
- **Model the tool users already know.** A CAD screen behaves like the CAD
viewers your users use; a forming screen like the forming tools they use.
Keep familiar mouse controls and names.
- **Nothing jumps.** No layout shift from messages, panels or loading.
- **Professional density.** Small type, tight rows, icons. Never big
friendly buttons.
- **Simple surface, depth underneath.** Show what most people need; put the
rest one click away, not on screen.
## 2. Words
- Plain words, short sentences. Write for a manufacturing engineer, not a
programmer.
- No internal codes, ids, hashes or stack traces in the interface. A
technical detail appears only when it adds to the plain sentence, behind
"Details".
- Errors say what happened and what to do: "The drawing has no tolerance
table. Add one, or enter tolerances under Settings." Not "Error 422" or
"Something went wrong".
- Sentence case for labels and buttons ("Save report", not "Save Report").
- Units always shown beside the value and the input (`10 mm`).
- Inexact values carry `~` with the reason on hover. Exact values carry no
mark.
## 3. Theme
- Use the host's tokens (CSS variables) for every colour. Never hard-code a
colour, and never assume the theme is dark.
- Cletus has three base themes (Amber, which is dark; Dark; Light) and
customer variants. Test all three.
- Engineering and data-dense screens set `"theme": "tool"`. Style controls
with the semantic tokens:
- toggled buttons: `--pressed-fill / -text / -border / -indicator`
- primary buttons: `--primary-*`
- the active tab: `--tab-indicator`
- density: `--tool-font` (12 px), `--tool-row` (22 px), `--tool-pad` (6 px)
- The accent colour is for thin indicators and small marks. No large filled
accent areas.
- One surface per card. No boxes in boxes.
- Judge theming by intent (readable, consistent, calm), not by pixels.
## 4. Layout
- **One job per region.**
- The left rail holds the thing being worked on (a model tree, a
document list, jobs).
- The right rail holds tools, in one collapsible `SidePanel` that floats
over the content and never resizes it. Collapsed, it is a slim icon
rail.
- The top toolbar holds view controls only.
- A tool's sub-tools appear as a small strip at the top of the content only
while that tool is active.
- A fixed-height status bar shows the selection, progress with cancel, and
dismissable failures. Errors never push content around.
- Floating cards (properties, details) open only when asked for, are movable
and closable, and never open on a click or right-click.
- Nothing a dialog opens may land under it. A setting a dialog needs appears
inside that dialog.
- Settings sit behind a gear at the bottom right, not on screen-switching
bars.
- Icons on every toolbar button (host `Icon` set), with the name and
shortcut in the tooltip.
## 5. Behaviour
- **Esc** stops what is running or closes the innermost thing open (a menu,
a live measurement, a panel). It never discards saved work.
- Every job over a second shows progress, an estimate where possible, and
cancel.
- Instant values compute on selection with "Computing…" in place. A failure
is a muted "unavailable" with the reason on hover, not a red banner.
- Selection is easy to clear: empty click, Esc, × on the status-bar chip,
right-click → Clear selection. Hover shows exactly what a click will pick.
- Right-click shows only a menu. Menus are compact and grouped, like
Cletus's own.
- A resize changes the aspect only. Never re-fit or re-zoom the user's view.
- Anything placed on the content (pins, labels, annotations) has a visible
way to remove it.
- Files dropped on chat only attach to chat. Your plugin opens a file only
when the user chooses "Open in <your plugin>" or drops it on your screen.
## 6. 3D and 2D viewports
- Use `sdk_viewport` for backgrounds and selection so every viewport looks
the same.
- Selection: a soft tint on faces and bodies; the full accent only on
selected edges and outlines. Never flood a part in the accent colour.
- Offer "Set orbit centre here" (trackpads have no middle button).
- Labels stay inside the viewport and hide or point off-screen when their
anchor is hidden. Never draw over panels or toolbars.
## 7. Chat tools
- One tool, one operation. No hidden retries or fallbacks inside a tool: fail
with the reason and the suggested next call, and let the AI decide.
- Results are short, factual and stable: the same input gives the same
text.
- Name tools `<short plugin name>_<verb>[_<object>]` (`gauge_check_run`).
- Descriptions say when to use the tool and when not to.
## 8. Go
- `gofmt`, `go vet`, `staticcheck` clean. Standard library first.
- Small packages under `internal/`, named for what they do. No `util`,
`common` or `helpers`.
- Errors wrap with context (`fmt.Errorf("read tolerance table: %w", err)`)
inside the worker; the message that reaches the user is a plain sentence.
- Every blocking call takes `ctx`. No goroutine without a way to stop it.
- Comments say why, not what. Exported names have doc comments.
## 9. TypeScript and Svelte
- Svelte 5 runes. An `$effect` never reads and writes the same `$state`.
- Strict TypeScript. No `any` without a comment.
- Components small and named for what they show.
- No network calls; data comes from your worker or published links.
## 10. Tests
- Every tool, service method and `ui.*` method has a Go test.
- With screens (a later release): every screen feature gets a browser test
that checks it changes what the user sees. Don't compare pixels.
- Fixtures are synthetic. Reproduce real-world failures with made-up files
that have the same shape.
+101
View File
@@ -0,0 +1,101 @@
# Submitting a plugin
**Submissions open with a later release.** This is how they will work, so
you can prepare now.
Plugins reach users only through the Eidonix plugin store. Cletus refuses
any plugin Eidonix has not signed. There is no other way to install one.
## 1. Become a publisher
Ask Eidonix for a publisher account. Eidonix checks:
- your company (registration documents);
- your web domain: you add a DNS TXT record
`_cletus-publisher.<your domain>` with the value Eidonix gives you;
- a signed publisher agreement (confidentiality, review, withdrawal).
You receive:
- your **publisher id** (the first half of every plugin id: `acme.…`);
- an account on `git.eidonix.com`, with a private repository for each plugin
(`plugin-submissions/acme-gauge-check`). Only you and Eidonix's review
team can see it;
- a place to register the SSH or GPG key you sign tags with.
Users see your verified company name and domain on every listing and
install screen.
## 2. Build and check
Develop wherever you like. Before submitting:
```sh
cletus-plugin check # the store's automated checks, locally
```
## 3. Submit
1. Push your code to your plugin's repository on `git.eidonix.com`.
2. Push a **signed tag** `v<version>` that matches `version` in
`plugin.json`. The tag is the submission.
Unsigned tags, tags signed by an unregistered key, and versions that were
used before are ignored.
Also include `listing/listing.json`:
```json
{
"summary": "Check measured gauges against the drawing's tolerances.",
"category": "Quality",
"visibility": "store",
"organizations": [],
"support": "support@acme.com",
"price": "free"
}
```
- `visibility` is `"store"` (every organization can find it) or
`"organizations"`: only the organizations listed, by name. Use that for a
plugin made for one customer.
- `price`: `"free"`, or `"paid"`. Paid plugins are arranged with Eidonix
directly for now; the store does not take payments yet.
## 4. What happens next
Progress shows as a status on your tag, and in an issue opened for each
submission in your repository. Review comments go in that issue.
| Status | Meaning |
| --- | --- |
| Building | Eidonix is building from your tag. |
| Checks failed | The build or an automated check failed. The issue says which; fix and submit a higher version. |
| In review | A reviewer is reading the code and running the plugin. |
| Changes requested | Fix what the issue lists and submit a higher version. |
| Approved | Waiting for Eidonix to sign and publish. |
| Published | Available in the store (or to the organizations listed). |
| Rejected | Not publishable. The issue says why. |
The first review of a plugin reads all of it. Updates are reviewed as the
difference from the last published version, so small, focused releases go
through faster. An update that adds a permission is reviewed in full, and
organizations must approve it again before their users get it.
## 5. After publishing
- Users choose when to update and may go back to earlier versions. Every
published version stays available unless withdrawn.
- You can withdraw a version or the plugin (ask in your repository's
issues). Withdrawn versions stop being offered; installed copies keep
working unless Eidonix revokes them.
- Eidonix revokes a version, plugin or publisher that is harmful or breaks
the agreement. A revoked plugin stops on every machine. Users keep the
files it saved.
- Problems users report reach you through your repository's issues.
## Your source code
- Only the built plugin is distributed. Your source is not published.
- How Eidonix may use, store and retain your source is set out in the
publisher agreement, which you sign before your first submission.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Cletus plugin kit
Copyright 2026 Eidonix
Licensed under the Apache License, Version 2.0 (see LICENSE).
Cletus and Eidonix are trademarks of Eidonix and are not licensed by it.
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// goEnv is the environment every build and test runs with: no cgo, no
// enclosing go.work, the plugin's vendor folder.
func goEnv(goos, goarch string) []string {
env := append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off")
if goos != "" {
env = append(env, "GOOS="+goos, "GOARCH="+goarch)
}
return env
}
// goRun runs a go command in dir and returns its combined output.
func goRun(dir string, env []string, args ...string) (string, error) {
cmd := exec.Command("go", args...)
cmd.Dir = dir
cmd.Env = env
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
return out.String(), err
}
// buildWorker builds ./cmd/worker for this machine into a temporary folder.
func buildWorker(dir string) (string, func(), error) {
tmp, err := os.MkdirTemp("", "cletus-plugin-")
if err != nil {
return "", nil, err
}
cleanup := func() { os.RemoveAll(tmp) }
name := "worker"
if runtime.GOOS == "windows" {
name += ".exe"
}
bin := filepath.Join(tmp, name)
if out, err := goRun(dir, goEnv("", ""), "build", "-trimpath", "-o", bin, "./cmd/worker"); err != nil {
cleanup()
return "", nil, fmt.Errorf("the worker doesn't build:\n%s", out)
}
return bin, cleanup, nil
}
+483
View File
@@ -0,0 +1,483 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
// report collects the outcome of each check.
type report struct {
failures, warnings int
}
func (r *report) ok(name string) { fmt.Printf("ok %s\n", name) }
func (r *report) fail(name, detail string) {
r.failures++
fmt.Printf("FAIL %s\n%s", name, indent(detail))
}
func (r *report) warn(name, detail string) {
r.warnings++
fmt.Printf("warn %s\n%s", name, indent(detail))
}
func indent(s string) string {
s = strings.TrimRight(s, "\n")
if s == "" {
return ""
}
return " " + strings.ReplaceAll(s, "\n", "\n ") + "\n"
}
func runCheck(args []string) error {
if len(args) > 0 {
return errors.New("check takes no arguments; run it in the plugin's folder")
}
r := &report{}
plugin, err := readPlugin(".")
if err != nil {
return err
}
if errs := plugin.Validate(); len(errs) > 0 {
var b strings.Builder
for _, e := range errs {
fmt.Fprintln(&b, e)
}
r.fail("plugin.json", b.String())
} else {
r.ok("plugin.json")
}
checkFiles(r, plugin)
own := ownGoFiles()
checkSources(r, own)
checkGoMod(r)
if out, err := goRun(".", goEnv("", ""), "vet", "./..."); err != nil {
r.fail("go vet", out)
} else {
r.ok("go vet")
}
checkImports(r, plugin)
checkDependencies(r)
for _, target := range [][2]string{{"darwin", "arm64"}, {"windows", "amd64"}} {
name := "build " + target[0] + "/" + target[1]
if out, err := goRun(".", goEnv(target[0], target[1]), "build", "-trimpath", "-o", os.DevNull, "./cmd/worker"); err != nil {
r.fail(name, out)
} else {
r.ok(name)
}
}
if out, err := goRun(".", goEnv("", ""), "test", "-count=1", "./..."); err != nil {
r.fail("go test", out)
} else {
r.ok("go test")
}
optionalTool(r, "staticcheck", "./...")
optionalTool(r, "govulncheck", "./...")
checkRuntime(r, plugin)
fmt.Println()
switch {
case r.failures > 0:
return fmt.Errorf("%d check(s) failed, %d warning(s)", r.failures, r.warnings)
case r.warnings > 0:
fmt.Printf("All checks passed with %d warning(s). Explain each in docs/plan.md; reviewers read them.\n", r.warnings)
default:
fmt.Println("All checks passed.")
}
return nil
}
func checkFiles(r *report, plugin *manifest.Plugin) {
var problems []string
changelog, err := os.ReadFile("CHANGELOG.md")
if err != nil {
problems = append(problems, "CHANGELOG.md is missing")
} else if !regexp.MustCompile(`(?m)^#+\s*v?` + regexp.QuoteMeta(plugin.Version) + `\b`).Match(changelog) {
problems = append(problems, "CHANGELOG.md has no heading for version "+plugin.Version)
}
for _, path := range []string{"docs/plan.md", "docs/guide.md", "go.mod", "cmd/worker"} {
if _, err := os.Stat(path); err != nil {
problems = append(problems, path+" is missing")
}
}
if plan, err := os.ReadFile("docs/plan.md"); err == nil {
for _, perm := range plugin.Permissions {
if !strings.Contains(string(plan), perm) {
problems = append(problems, "docs/plan.md doesn't explain the "+perm+" permission")
}
}
}
if len(problems) > 0 {
r.fail("repository files", strings.Join(problems, "\n"))
} else {
r.ok("repository files")
}
}
// ownGoFiles lists the plugin's own Go files (not vendor/, not hidden folders).
func ownGoFiles() []string {
var files []string
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && path != "." && (d.Name() == "vendor" || strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") {
return filepath.SkipDir
}
if !d.IsDir() && strings.HasSuffix(path, ".go") {
files = append(files, path)
}
return nil
})
return files
}
var (
secretRE = regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|\bsk-[A-Za-z0-9_-]{20,}|\bghp_[A-Za-z0-9]{30,}|\bxox[abp]-[A-Za-z0-9-]{10,}`)
fileOpRE = regexp.MustCompile(`\bos\.(Open|OpenFile|ReadFile|WriteFile|Create|Remove|RemoveAll|ReadDir|Rename|Mkdir|MkdirAll)\(`)
linknameRE = regexp.MustCompile(`//go:linkname`)
)
func checkSources(r *report, files []string) {
var gofmtBad, cgo, secrets, linkname, fileOps []string
if len(files) > 0 {
out, _ := exec.Command("gofmt", append([]string{"-l"}, files...)...).Output()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
gofmtBad = append(gofmtBad, line)
}
}
}
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
if d != nil && d.IsDir() && path != "." && strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil || info.Size() > 4<<20 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
text := string(data)
vendored := strings.HasPrefix(filepath.ToSlash(path), "vendor/")
if strings.HasSuffix(path, ".go") && regexp.MustCompile(`(?m)^import\s+"C"`).MatchString(text) {
cgo = append(cgo, path)
}
if !vendored && secretRE.MatchString(text) {
secrets = append(secrets, path)
}
if strings.HasSuffix(path, ".go") && !vendored {
if linknameRE.MatchString(text) {
linkname = append(linkname, path)
}
if !strings.HasSuffix(path, "_test.go") && fileOpRE.MatchString(text) {
fileOps = append(fileOps, path)
}
}
return nil
})
if len(gofmtBad) > 0 {
r.fail("gofmt", "not formatted (run gofmt -w):\n"+strings.Join(gofmtBad, "\n"))
} else {
r.ok("gofmt")
}
if len(cgo) > 0 {
r.fail("no cgo", "these files use cgo, which Cletus plugins can't:\n"+strings.Join(cgo, "\n"))
} else {
r.ok("no cgo")
}
if len(secrets) > 0 {
r.fail("no secrets", "these files look like they hold a key or token:\n"+strings.Join(secrets, "\n"))
} else {
r.ok("no secrets")
}
if len(linkname) > 0 {
r.fail("no go:linkname", strings.Join(linkname, "\n"))
}
if len(fileOps) > 0 {
r.warn("direct file access", "these files open, write or delete files directly. Each path must come from\nhost.files.resolve, a dialog, or pluginsdk.DataDir(); anything else needs files.computer:\n"+strings.Join(fileOps, "\n"))
}
}
func checkGoMod(r *report) {
data, err := os.ReadFile("go.mod")
if err != nil {
return
}
text := string(data)
var problems []string
if regexp.MustCompile(`(?m)^\s*replace\b|=>`).MatchString(text) {
problems = append(problems, "go.mod has replace directives; the store builds only from go.mod and vendor/")
}
if strings.Contains(text, "require") {
if _, err := os.Stat("vendor/modules.txt"); err != nil {
problems = append(problems, "vendor/ is missing; run go mod vendor and commit it")
} else if out, err := goRun(".", goEnv("", ""), "build", "-mod=vendor", "-o", os.DevNull, "./cmd/worker"); err != nil {
problems = append(problems, "vendor/ doesn't match go.mod; run go mod vendor:\n"+out)
}
}
if len(problems) > 0 {
r.fail("go.mod and vendor", strings.Join(problems, "\n"))
} else {
r.ok("go.mod and vendor")
}
}
type goPackage struct {
ImportPath string
Imports []string
Standard bool
Module *struct{ Path string }
}
func listPackages(args ...string) ([]goPackage, error) {
cmd := exec.Command("go", append([]string{"list", "-json"}, args...)...)
cmd.Env = goEnv("", "")
out, err := cmd.Output()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return nil, fmt.Errorf("%s", exit.Stderr)
}
return nil, err
}
var pkgs []goPackage
dec := json.NewDecoder(strings.NewReader(string(out)))
for {
var p goPackage
if err := dec.Decode(&p); err == io.EOF {
break
} else if err != nil {
return nil, err
}
pkgs = append(pkgs, p)
}
return pkgs, nil
}
var netPackages = map[string]bool{"net": true, "net/http": true, "net/rpc": true, "net/smtp": true, "crypto/tls": true, "net/http/httputil": true}
func checkImports(r *report, plugin *manifest.Plugin) {
own, err := listPackages("./...")
if err != nil {
r.fail("imports", err.Error())
return
}
var failures, notes []string
for _, p := range own {
for _, imp := range p.Imports {
switch {
case imp == "plugin":
failures = append(failures, p.ImportPath+" imports plugin (loading code at run time is not allowed)")
case imp == "os/exec" && !plugin.Has("processes"):
failures = append(failures, p.ImportPath+" imports os/exec but the plugin doesn't declare processes")
case netPackages[imp] && !plugin.HasNet():
failures = append(failures, p.ImportPath+" imports "+imp+" but the plugin declares no net permission")
case imp == "unsafe" || imp == "reflect" || imp == "syscall" || strings.HasPrefix(imp, "golang.org/x/sys/"):
notes = append(notes, p.ImportPath+" imports "+imp)
}
}
}
if len(failures) > 0 {
r.fail("imports match permissions", strings.Join(failures, "\n"))
} else {
r.ok("imports match permissions")
}
if len(notes) > 0 {
r.warn("low-level imports", "explain each in docs/plan.md:\n"+strings.Join(notes, "\n"))
}
deps, err := listPackages("-deps", "./cmd/worker")
if err != nil {
return
}
var depNotes []string
for _, p := range deps {
if p.Standard || p.Module == nil || isOwnModule(p.Module.Path) {
continue
}
for _, imp := range p.Imports {
if imp == "os/exec" && !plugin.Has("processes") || netPackages[imp] && !plugin.HasNet() {
depNotes = append(depNotes, p.ImportPath+" imports "+imp)
}
}
}
if len(depNotes) > 0 {
sort.Strings(depNotes)
r.warn("dependencies that can start programs or connect", "your plugin doesn't declare this; make sure these code paths are never used:\n"+strings.Join(depNotes, "\n"))
}
}
func isOwnModule(path string) bool {
data, err := os.ReadFile("go.mod")
if err != nil {
return false
}
m := regexp.MustCompile(`(?m)^module\s+(\S+)`).FindStringSubmatch(string(data))
return len(m) == 2 && m[1] == path
}
func checkDependencies(r *report) {
f, err := os.Open("vendor/modules.txt")
if err != nil {
return
}
defer f.Close()
var missing []string
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 3 || fields[0] != "#" || fields[2] == "=>" {
continue
}
dir := filepath.Join("vendor", filepath.FromSlash(fields[1]))
if _, err := os.Stat(dir); err != nil {
continue // a module with no packages in the build
}
if !hasLicense(dir) {
missing = append(missing, fields[1]+" "+fields[2])
}
}
if len(missing) > 0 {
r.warn("dependency licences", "no licence file found; the store must be able to confirm each licence allows distribution:\n"+strings.Join(missing, "\n"))
} else {
r.ok("dependency licences present")
}
}
func hasLicense(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
name := strings.ToUpper(e.Name())
if strings.HasPrefix(name, "LICENSE") || strings.HasPrefix(name, "LICENCE") || strings.HasPrefix(name, "COPYING") {
return true
}
}
return false
}
func optionalTool(r *report, tool string, args ...string) {
if _, err := exec.LookPath(tool); err != nil {
fmt.Printf("skip %s (not installed; the store runs it)\n", tool)
return
}
cmd := exec.Command(tool, args...)
cmd.Env = goEnv("", "")
out, err := cmd.CombinedOutput()
if err != nil {
r.fail(tool, string(out))
} else {
r.ok(tool)
}
}
// checkRuntime starts the worker and compares its tools with plugin.json.
func checkRuntime(r *report, plugin *manifest.Plugin) {
wd, _ := os.Getwd()
bin, cleanup, err := buildWorker(wd)
if err != nil {
r.fail("worker starts", err.Error())
return
}
defer cleanup()
tmp, err := os.MkdirTemp("", "cletus-plugin-check-")
if err != nil {
r.fail("worker starts", err.Error())
return
}
defer os.RemoveAll(tmp)
log := &syncBuffer{}
host := &testHost{plugin: plugin, filesDir: tmp, dataDir: filepath.Join(tmp, "data"), capabilities: map[string]bool{}, log: log}
if err := host.start(bin); err != nil {
r.fail("worker starts", err.Error()+"\n"+log.String())
return
}
defer host.stop()
r.ok("worker starts and answers the handshake")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var tools []struct {
Def struct {
Function struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"function"`
} `json:"def"`
Mutating bool `json:"mutating"`
RequiresApproval bool `json:"requires_approval"`
}
if err := host.call(ctx, "tools.list", nil, &tools); err != nil {
r.fail("tools match plugin.json", "tools.list: "+err.Error())
return
}
declared := map[string]manifest.Tool{}
for _, t := range plugin.Tools {
declared[t.Name] = t
}
var problems []string
for _, t := range tools {
name := t.Def.Function.Name
d, ok := declared[name]
switch {
case !ok:
problems = append(problems, "the worker offers "+name+", which plugin.json doesn't list")
case d.Mutating != t.Mutating || d.RequiresApproval != t.RequiresApproval:
problems = append(problems, name+": mutating or requires_approval differs between plugin.json and the worker")
}
if strings.TrimSpace(t.Def.Function.Description) == "" {
problems = append(problems, name+" has no description")
}
delete(declared, name)
}
for name := range declared {
problems = append(problems, "plugin.json lists "+name+", which the worker doesn't offer")
}
if len(problems) > 0 {
sort.Strings(problems)
r.fail("tools match plugin.json", strings.Join(problems, "\n"))
} else {
r.ok("tools match plugin.json")
}
}
// syncBuffer is a strings.Builder safe for the worker's log and the host's
// call log to write at once.
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
+323
View File
@@ -0,0 +1,323 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
type message struct {
ID string `json:"id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// testHost runs a worker and answers its host calls the way Cletus does,
// within the plugin's declared permissions. Every host call is logged.
type testHost struct {
plugin *manifest.Plugin
filesDir string // the folder the user "opened"
workspaceDir string // backs the one simulated shared folder ("" = none)
dataDir string
edit bool
capabilities map[string]bool
log io.Writer
cmd *exec.Cmd
stdin io.WriteCloser
writeMu sync.Mutex
mu sync.Mutex
pending map[string]chan message
next int
exited chan struct{}
}
const testWorkspaceID = "ws:00000000-0000-0000-0000-000000000001"
func (h *testHost) start(bin string) error {
if err := os.MkdirAll(h.dataDir, 0o755); err != nil {
return err
}
h.cmd = exec.Command(bin)
h.cmd.Dir = h.dataDir
h.cmd.Env = []string{"CLETUS_PLUGIN_ID=" + h.plugin.ID, "CLETUS_PLUGIN_DATA=" + h.dataDir}
for _, name := range []string{"HOME", "USERPROFILE", "PATH", "TMPDIR", "TEMP", "TMP", "LANG", "SystemRoot"} {
if v, ok := os.LookupEnv(name); ok {
h.cmd.Env = append(h.cmd.Env, name+"="+v)
}
}
h.cmd.Stderr = prefixWriter{w: h.log, prefix: "worker "}
var err error
if h.stdin, err = h.cmd.StdinPipe(); err != nil {
return err
}
stdout, err := h.cmd.StdoutPipe()
if err != nil {
return err
}
h.pending = map[string]chan message{}
h.exited = make(chan struct{})
if err := h.cmd.Start(); err != nil {
return err
}
go h.read(stdout)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var hs struct {
APIVersion int `json:"api_version"`
}
if err := h.call(ctx, "plugin.handshake", nil, &hs); err != nil {
return fmt.Errorf("handshake: %w", err)
}
if hs.APIVersion != 1 {
return fmt.Errorf("handshake: the worker speaks api_version %d; Cletus needs 1", hs.APIVersion)
}
return nil
}
func (h *testHost) stop() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = h.call(ctx, "plugin.shutdown", nil, nil)
cancel()
h.stdin.Close()
select {
case <-h.exited:
case <-time.After(2 * time.Second):
_ = h.cmd.Process.Kill()
<-h.exited
}
}
func (h *testHost) read(stdout io.Reader) {
defer close(h.exited)
defer func() { _ = h.cmd.Wait() }()
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 64<<10), 64<<20+1)
for sc.Scan() {
var m message
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
fmt.Fprintf(h.log, "host the worker wrote something that isn't a message (is it printing to stdout?): %.200s\n", sc.Text())
continue
}
switch {
case m.Method == "$/cancel":
case m.Method != "":
go h.serve(m)
default:
h.mu.Lock()
ch := h.pending[m.ID]
delete(h.pending, m.ID)
h.mu.Unlock()
if ch != nil {
ch <- m
}
}
}
h.mu.Lock()
for id, ch := range h.pending {
close(ch)
delete(h.pending, id)
}
h.mu.Unlock()
}
func (h *testHost) send(m message) error {
data, err := json.Marshal(m)
if err != nil {
return err
}
h.writeMu.Lock()
defer h.writeMu.Unlock()
_, err = h.stdin.Write(append(data, '\n'))
return err
}
func (h *testHost) call(ctx context.Context, method string, params, out any) error {
var raw json.RawMessage
if params != nil {
var err error
if raw, err = json.Marshal(params); err != nil {
return err
}
}
ch := make(chan message, 1)
h.mu.Lock()
h.next++
id := fmt.Sprintf("h%d", h.next)
h.pending[id] = ch
h.mu.Unlock()
if err := h.send(message{ID: id, Method: method, Params: raw}); err != nil {
return fmt.Errorf("the worker has stopped: %w", err)
}
select {
case reply, ok := <-ch:
if !ok {
return errors.New("the worker stopped before answering")
}
if reply.Error != nil {
return errors.New(reply.Error.Message)
}
if out != nil {
return json.Unmarshal(reply.Result, out)
}
return nil
case <-ctx.Done():
cancelID, _ := json.Marshal(id)
_ = h.send(message{Method: "$/cancel", Params: cancelID})
return ctx.Err()
}
}
// serve answers one host call from the worker.
func (h *testHost) serve(m message) {
result, err := h.answer(m.Method, m.Params)
reply := message{ID: m.ID}
outcome := "ok"
if err != nil {
reply.Error = &struct {
Message string `json:"message"`
}{err.Error()}
outcome = "error: " + err.Error()
} else {
reply.Result, _ = json.Marshal(result)
}
fmt.Fprintf(h.log, "host %s %s → %s\n", m.Method, clip(string(m.Params), 200), outcome)
if m.ID != "" {
_ = h.send(reply)
}
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n] + "…"
}
return s
}
func (h *testHost) answer(method string, raw json.RawMessage) (any, error) {
if ok, need := h.plugin.Allowed(method); !ok {
if need != "" {
return nil, fmt.Errorf("this plugin is not allowed to use %s; it needs the %s permission", method, need)
}
return nil, fmt.Errorf("unknown plugin host operation %q", method)
}
var p struct {
Path, Key, Workspace, Prefix, Name, Data, ContentType string
TTL int
Args []any
}
_ = json.Unmarshal(raw, &p)
switch method {
case "host.auth.has_capability":
var c struct{ Capability string }
_ = json.Unmarshal(raw, &c)
return map[string]bool{"granted": h.capabilities[c.Capability]}, nil
case "host.auth.whoami":
return map[string]any{"key_id": 1, "name": "Test User", "org": "Test Organization", "org_id": "00000000-0000-0000-0000-000000000000"}, nil
case "host.files.resolve":
return h.resolve(p.Path)
case "host.files.can_write":
path, err := h.resolve(p.Path)
if err != nil {
path, err = h.within(p.Path)
}
return err == nil && h.edit && path != "", nil
case "host.event":
if !strings.HasPrefix(p.Name, h.plugin.ID+":") {
return nil, errors.New("plugin event is not permitted")
}
return nil, nil
case "host.screen.open":
return nil, errors.New("screens aren't available yet")
case "host.dialog.open", "host.dialog.folder", "host.dialog.save", "host.dialog.message":
return nil, errors.New("the test host has no dialogs; pass paths as tool arguments while testing")
case "host.plugin.call":
return nil, errors.New("the test host runs no other plugins")
case "host.data.publish":
path, err := h.inData(p.Path)
if err != nil {
return nil, err
}
if p.TTL < 1 || p.TTL > 3600 {
return nil, errors.New("artifact ttl must be 1 to 3600 seconds")
}
return map[string]string{"token": "test-token", "url": "file://" + filepath.ToSlash(path), "expiresAt": time.Now().Add(time.Duration(p.TTL) * time.Second).UTC().Format(time.RFC3339)}, nil
case "host.data.revoke":
return nil, nil
case "host.data.leases":
return 0, nil
case "host.data.remove":
path, err := h.inData(p.Path)
if err != nil {
return nil, err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return nil, err
}
return nil, nil
case "host.agent.start", "host.agent.status", "host.agent.cancel":
return nil, errors.New("background runs aren't available in the test host")
}
if strings.HasPrefix(method, "host.workspace.") {
return h.workspace(method, p.Workspace, p.Key, p.Prefix, p.Path, p.Data)
}
return nil, fmt.Errorf("unknown plugin host operation %q", method)
}
// resolve answers host.files.resolve: a path inside the opened folder.
func (h *testHost) resolve(path string) (string, error) {
resolved, err := h.within(path)
if err != nil {
return "", err
}
if _, err := os.Stat(resolved); err != nil {
return "", fmt.Errorf("%s does not exist", path)
}
return resolved, nil
}
func (h *testHost) within(path string) (string, error) {
if path == "" {
return "", errors.New("path is empty")
}
if !filepath.IsAbs(path) {
path = filepath.Join(h.filesDir, path)
}
path = filepath.Clean(path)
if !inside(h.filesDir, path) {
return "", fmt.Errorf("%s is outside the files the user opened", path)
}
return path, nil
}
func (h *testHost) inData(path string) (string, error) {
if !filepath.IsAbs(path) {
path = filepath.Join(h.dataDir, path)
}
path = filepath.Clean(path)
if !inside(h.dataDir, path) {
return "", errors.New("the path must be inside the plugin's data folder")
}
return path, nil
}
func inside(root, path string) bool {
rel, err := filepath.Rel(root, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
func newTestHost(t *testing.T, perms ...string) *testHost {
t.Helper()
root := t.TempDir()
files, ws := filepath.Join(root, "files"), filepath.Join(root, "ws")
for _, d := range []string{files, ws} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(files, "a.txt"), []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws, "ref.csv"), []byte("x,y"), 0o644); err != nil {
t.Fatal(err)
}
return &testHost{plugin: &manifest.Plugin{ID: "acme.demo", Permissions: perms}, filesDir: files, workspaceDir: ws,
dataDir: filepath.Join(root, "data"), capabilities: map[string]bool{}}
}
func ask(t *testing.T, h *testHost, method string, params any) (any, error) {
t.Helper()
raw, _ := json.Marshal(params)
return h.answer(method, raw)
}
func TestPermissionsGateHostCalls(t *testing.T) {
h := newTestHost(t)
_, err := ask(t, h, "host.files.resolve", map[string]string{"path": "a.txt"})
if err == nil || !strings.Contains(err.Error(), "needs the files permission") {
t.Fatalf("undeclared files call = %v", err)
}
if _, err := ask(t, h, "host.code.llm.start", nil); err == nil || !strings.Contains(err.Error(), "unknown plugin host operation") {
t.Fatalf("first-party call = %v", err)
}
if _, err := ask(t, h, "host.event", map[string]any{"name": "other:x"}); err == nil {
t.Fatal("an event outside the plugin's prefix was allowed")
}
if _, err := ask(t, h, "host.event", map[string]any{"name": "acme.demo:x"}); err != nil {
t.Fatalf("own event = %v", err)
}
}
func TestFilesStayInsideTheOpenedFolder(t *testing.T) {
h := newTestHost(t, "files")
got, err := ask(t, h, "host.files.resolve", map[string]string{"path": "a.txt"})
if err != nil || got != filepath.Join(h.filesDir, "a.txt") {
t.Fatalf("resolve = %v, %v", got, err)
}
for _, path := range []string{"../escape.txt", "/etc/hosts"} {
if _, err := ask(t, h, "host.files.resolve", map[string]string{"path": path}); err == nil {
t.Errorf("resolve(%s) was allowed", path)
}
}
if got, _ := ask(t, h, "host.files.can_write", map[string]string{"path": "new.txt"}); got != false {
t.Fatal("can_write without -edit should be false")
}
h.edit = true
if got, _ := ask(t, h, "host.files.can_write", map[string]string{"path": "new.txt"}); got != true {
t.Fatal("can_write with -edit should be true")
}
}
func TestWorkspaceSimulation(t *testing.T) {
h := newTestHost(t, "files", "workspace.read", "workspace.write")
listed, err := ask(t, h, "host.workspace.list", map[string]string{"workspace": testWorkspaceName})
if err != nil || len(listed.([]map[string]any)) != 1 {
t.Fatalf("list = %v, %v", listed, err)
}
key := testWorkspaceID + "/files/ref.csv"
fetched, err := ask(t, h, "host.workspace.fetch", map[string]string{"key": key})
if err != nil {
t.Fatal(err)
}
path := fetched.(map[string]string)["path"]
if _, err := ask(t, h, "host.files.resolve", map[string]string{"path": path}); err != nil {
t.Fatalf("a fetched copy should resolve: %v", err)
}
data := base64.StdEncoding.EncodeToString([]byte("new"))
if _, err := ask(t, h, "host.workspace.put", map[string]string{"workspace": testWorkspaceID, "key": "out/r.txt", "data": data}); err != nil {
t.Fatal(err)
}
if _, err := ask(t, h, "host.workspace.put", map[string]string{"workspace": testWorkspaceID, "key": "../x", "data": data}); err == nil {
t.Fatal("a key with .. was accepted")
}
if _, err := ask(t, h, "host.workspace.read", map[string]string{"key": testWorkspaceID + "/files/out/r.txt"}); err != nil {
t.Fatal(err)
}
}
+61
View File
@@ -0,0 +1,61 @@
// Command cletus-plugin creates, checks and runs Cletus plugins.
//
// cletus-plugin new <publisher>.<name> [folder]
// cletus-plugin check
// cletus-plugin run [flags] tools | invoke <tool> [json] | ui <method> [json array] | service <service.method> [json]
package main
import (
"fmt"
"os"
)
// sdkVersion is the SDK release new plugins require (tag sdk/<version>).
const sdkVersion = "v0.1.0"
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "new":
err = runNew(os.Args[2:])
case "check":
err = runCheck(os.Args[2:])
case "run":
err = runRun(os.Args[2:])
case "version":
fmt.Println("cletus-plugin", sdkVersion)
case "help", "-h", "--help":
usage()
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "cletus-plugin:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `cletus-plugin builds Cletus plugins.
cletus-plugin new <publisher>.<name> [folder] create a plugin from the template
cletus-plugin check run the store's automated checks here
cletus-plugin run [flags] <command> run the worker under the test host
tools list the chat tools
invoke <tool> [json] call a chat tool
ui <method> [json array] call a screen method
service <service.method> [json] call a provided service
flags: -files <dir> the folder the user "opened" (default: testdata)
-workspace <dir> simulate one shared folder backed by this directory
-edit the user allows edits (default: read-only)
-capability <name> a Cletus feature the user has (repeatable)
cletus-plugin version
Docs: https://git.eidonix.com/eidonix/cletus-plugins
`)
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"bytes"
"embed"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"text/template"
"unicode"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
//go:embed all:template
var templateFS embed.FS
const sdkModule = "git.eidonix.com/eidonix/cletus-plugins/sdk"
type templateData struct {
ID, Publisher, Name, Title, Short, Module, SDKModule, SDKVersion string
}
func runNew(args []string) error {
fs := flag.NewFlagSet("new", flag.ContinueOnError)
sdkDir := fs.String("sdk-dir", "", "use a local copy of the SDK (for developing the SDK itself)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 1 || fs.NArg() > 2 {
return errors.New("usage: cletus-plugin new <publisher>.<name> [folder]")
}
id := fs.Arg(0)
probe := manifest.Plugin{ID: id, Name: "x", Description: "x", Version: "0.1.0", MinAppVersion: "0.5.0", HostAPI: manifest.HostAPI}
if errs := probe.Validate(); len(errs) > 0 {
return errs[0]
}
publisher, name, _ := strings.Cut(id, ".")
dir := name
if fs.NArg() == 2 {
dir = fs.Arg(1)
}
if _, err := os.Stat(dir); err == nil {
return fmt.Errorf("%s already exists", dir)
}
data := templateData{ID: id, Publisher: publisher, Name: name, Title: title(name),
Short: strings.ReplaceAll(name, "-", "_"), Module: publisher + ".plugins/" + name,
SDKModule: sdkModule, SDKVersion: sdkVersion}
if err := writeTemplate(dir, data); err != nil {
return err
}
fmt.Printf("Created %s in %s.\n", id, dir)
if *sdkDir != "" {
abs, err := filepath.Abs(*sdkDir)
if err != nil {
return err
}
// Only for developing the SDK: check refuses replace directives.
if out, err := goRun(dir, goEnv("", ""), "mod", "edit", "-replace", sdkModule+"="+abs); err != nil {
return fmt.Errorf("go mod edit: %s", out)
}
}
for _, step := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
if out, err := goRun(dir, goEnv("", ""), step...); err != nil {
return fmt.Errorf("go %s failed. Fix the problem below and run it in %s yourself:\n%s", strings.Join(step, " "), dir, out)
}
}
fmt.Printf("\nNext:\n cd %s\n cletus-plugin check\n cletus-plugin run tools\n cletus-plugin run invoke %s_count_lines '{\"path\":\"sample.txt\"}'\n", dir, data.Short)
return nil
}
func writeTemplate(dir string, data templateData) error {
return fs.WalkDir(templateFS, "template", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel := strings.TrimPrefix(path, "template/")
rel = strings.TrimSuffix(rel, ".tmpl")
rel = strings.ReplaceAll(rel, "_dot_", ".")
src, err := templateFS.ReadFile(path)
if err != nil {
return err
}
t, err := template.New(rel).Delims("[[", "]]").Parse(string(src))
if err != nil {
return fmt.Errorf("template %s: %w", rel, err)
}
var out bytes.Buffer
if err := t.Execute(&out, data); err != nil {
return fmt.Errorf("template %s: %w", rel, err)
}
dst := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, out.Bytes(), 0o644)
})
}
func title(name string) string {
words := strings.Split(name, "-")
for i, w := range words {
if w != "" {
r := []rune(w)
r[0] = unicode.ToUpper(r[0])
words[i] = string(r)
}
}
return strings.Join(words, " ")
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
type listFlag []string
func (l *listFlag) String() string { return strings.Join(*l, ",") }
func (l *listFlag) Set(v string) error { *l = append(*l, v); return nil }
func runRun(args []string) error {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
files := fs.String("files", "testdata", "the folder the user opened")
workspace := fs.String("workspace", "", "a directory to serve as one shared folder")
edit := fs.Bool("edit", false, "the user allows edits")
timeout := fs.Duration("timeout", 2*time.Minute, "how long to wait for the answer")
var caps listFlag
fs.Var(&caps, "capability", "a Cletus feature the user has (repeatable)")
if err := fs.Parse(args); err != nil {
return err
}
rest := fs.Args()
if len(rest) == 0 {
return errors.New("run needs a command: tools, invoke, ui or service")
}
plugin, err := readPlugin(".")
if err != nil {
return err
}
wd, _ := os.Getwd()
filesDir, err := filepath.Abs(*files)
if err != nil {
return err
}
if err := os.MkdirAll(filesDir, 0o755); err != nil {
return err
}
wsDir := ""
if *workspace != "" {
if wsDir, err = filepath.Abs(*workspace); err != nil {
return err
}
}
bin, cleanup, err := buildWorker(wd)
if err != nil {
return err
}
defer cleanup()
host := &testHost{plugin: plugin, filesDir: filesDir, workspaceDir: wsDir, edit: *edit,
dataDir: filepath.Join(wd, ".cletus-dev", "data"), capabilities: map[string]bool{}, log: os.Stderr}
for _, c := range caps {
host.capabilities[c] = true
}
if err := host.start(bin); err != nil {
return err
}
defer host.stop()
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
arg := func(i int, fallback string) string {
if len(rest) > i {
return rest[i]
}
return fallback
}
var out json.RawMessage
switch rest[0] {
case "tools":
err = host.call(ctx, "tools.list", nil, &out)
case "invoke":
if len(rest) < 2 {
return errors.New("invoke needs a tool name")
}
err = host.call(ctx, "tools.invoke", map[string]any{"name": rest[1], "args": arg(2, "{}"), "working_dir": filesDir, "edit_mode": *edit}, &out)
case "ui":
if len(rest) < 2 {
return errors.New("ui needs a method name")
}
err = host.call(ctx, "ui."+strings.TrimPrefix(rest[1], "ui."), json.RawMessage(arg(2, "[]")), &out)
case "service":
if len(rest) < 2 {
return errors.New("service needs <service>.<method>")
}
err = host.call(ctx, "svc."+rest[1], map[string]any{"caller": "cletus-plugin", "mutating": false, "args": json.RawMessage(arg(2, "{}"))}, &out)
default:
return fmt.Errorf("unknown run command %q", rest[0])
}
if err != nil {
return err
}
var pretty bytes.Buffer
if json.Indent(&pretty, out, "", " ") != nil {
pretty.Write(out)
}
fmt.Println(pretty.String())
return nil
}
func readPlugin(dir string) (*manifest.Plugin, error) {
path := filepath.Join(dir, "plugin.json")
if _, err := os.Stat(path); err != nil {
return nil, manifest.ErrNoManifest
}
return manifest.Read(path)
}
// prefixWriter prefixes every line written to w.
type prefixWriter struct {
w io.Writer
prefix string
}
func (p prefixWriter) Write(b []byte) (int, error) {
for _, line := range strings.SplitAfter(string(b), "\n") {
if line != "" {
fmt.Fprint(p.w, p.prefix+line)
}
}
return len(b), nil
}
@@ -0,0 +1,21 @@
# Rules for coding agents
This is a Cletus plugin. Before changing anything, read the plugin kit and
follow its rules exactly:
- Rules: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/AGENTS.md
- Specification: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/specification.md
- Host API: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/host-api.md
- Style guide: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/style-guide.md
- Review checklist: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/review-checklist.md
If you can't read them, stop and ask the user for a copy of the kit.
Commands:
cletus-plugin check # must pass before you say you're done
cletus-plugin run tools # list the chat tools
cletus-plugin run invoke <tool> '<json>'
go test ./...
Keep docs/plan.md, plugin.json and CHANGELOG.md in step with the code.
@@ -0,0 +1,5 @@
# Changelog
## 0.1.0
- First version: count the lines in a text file, and save a summary next to it.
@@ -0,0 +1 @@
@AGENTS.md
@@ -0,0 +1,2 @@
/.cletus-dev/
/testdata/.cletus/
@@ -0,0 +1,18 @@
// Command worker is the [[.Title]] plugin's worker.
package main
import (
"fmt"
"os"
"[[.SDKModule]]/pluginsdk"
"[[.Module]]/internal/tools"
)
func main() {
if err := pluginsdk.Serve(pluginsdk.Handlers{Tools: tools.All()}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,8 @@
# [[.Title]]
Ask Cletus to count the lines in a file, or to save a summary of it.
- "How many lines are in report.txt?"
- "Save a line-count summary next to report.txt."
Saving needs Cletus to allow edits in the folder.
@@ -0,0 +1,25 @@
# [[.Title]]: plan
## What it does
Counts the lines in a text file the user chooses, and saves a short summary
next to it.
## Chat tools
- `[[.Short]]_count_lines`: reads a file and returns its line count. Read-only.
- `[[.Short]]_save_summary`: writes `<file>.summary.txt` next to the file.
Mutating; checks `host.files.can_write` first.
## Permissions
- `files`: to read the file the user chose and write the summary beside it.
## Dependencies
- `[[.SDKModule]]`: the Cletus plugin SDK.
## Notes for review
- Direct file access: `internal/tools` reads and writes only paths returned
by `host.files.resolve`, after `host.files.can_write` for writes.
@@ -0,0 +1,5 @@
module [[.Module]]
go 1.24
require [[.SDKModule]] [[.SDKVersion]]
@@ -0,0 +1,88 @@
// Package tools holds the plugin's chat tools.
package tools
import (
"bytes"
"context"
"fmt"
"os"
"[[.SDKModule]]/pluginsdk"
)
// All returns every chat tool. Each is also listed in plugin.json.
func All() []pluginsdk.Tool {
return []pluginsdk.Tool{countLines, saveSummary}
}
var pathParam = map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "The text file, as the user named it."},
},
"required": []string{"path"},
}
var countLines = pluginsdk.Tool{
Name: "[[.Short]]_count_lines",
Description: "Count the lines in a text file. Use when the user asks how long a text file is. Returns the line count.",
Parameters: pathParam,
Run: func(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) pluginsdk.ToolResult {
path, lines, err := count(ctx, h, call)
if err != nil {
return pluginsdk.Fail("%v", err)
}
return pluginsdk.Text("%s has %d lines.", path, lines)
},
}
var saveSummary = pluginsdk.Tool{
Name: "[[.Short]]_save_summary",
Description: "Save a one-line summary (the line count) next to a text file, as <file>.summary.txt. Use only when the user asks to save it.",
Parameters: pathParam,
Mutating: true,
Run: func(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) pluginsdk.ToolResult {
path, lines, err := count(ctx, h, call)
if err != nil {
return pluginsdk.Fail("%v", err)
}
out := path + ".summary.txt"
allowed, err := h.CanWrite(ctx, out)
if err != nil {
return pluginsdk.Fail("%v", err)
}
if !allowed || !call.EditMode {
return pluginsdk.Fail("Cletus doesn't allow edits in this folder. Ask the user to allow edits, then try again.")
}
if err := os.WriteFile(out, []byte(fmt.Sprintf("%d lines\n", lines)), 0o644); err != nil {
return pluginsdk.Fail("Couldn't save the summary: %v", err)
}
return pluginsdk.Text("Saved %s.", out)
},
}
// count resolves the path through the host and counts its lines.
func count(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) (string, int, error) {
var args struct {
Path string `json:"path"`
}
if err := call.Decode(&args); err != nil {
return "", 0, err
}
if args.Path == "" {
return "", 0, fmt.Errorf("give the path of a text file")
}
path, err := h.ResolvePath(ctx, args.Path)
if err != nil {
return "", 0, err
}
data, err := os.ReadFile(path)
if err != nil {
return "", 0, fmt.Errorf("couldn't read %s: %v", args.Path, err)
}
lines := bytes.Count(data, []byte("\n"))
if len(data) > 0 && data[len(data)-1] != '\n' {
lines++
}
return path, lines, nil
}
@@ -0,0 +1,73 @@
package tools
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"[[.SDKModule]]/pluginsdk"
)
// fakeHost answers host.files.* for files inside dir, like Cletus does for
// the folder the user opened.
func fakeHost(dir string, canWrite bool) *pluginsdk.Host {
return pluginsdk.NewTestHost(func(method string, params json.RawMessage) (any, error) {
var p struct{ Path string }
_ = json.Unmarshal(params, &p)
path := p.Path
if !filepath.IsAbs(path) {
path = filepath.Join(dir, path)
}
switch method {
case "host.files.resolve":
if _, err := os.Stat(path); err != nil {
return nil, errors.New(p.Path + " does not exist")
}
return path, nil
case "host.files.can_write":
return canWrite, nil
}
return nil, errors.New("unexpected host call " + method)
})
}
func run(t *testing.T, tool pluginsdk.Tool, h *pluginsdk.Host, args string, edit bool) pluginsdk.ToolResult {
t.Helper()
return tool.Run(context.Background(), h, pluginsdk.ToolCall{Args: json.RawMessage(args), EditMode: edit})
}
func TestCountLines(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\ntwo\nthree"), 0o644); err != nil {
t.Fatal(err)
}
got := run(t, countLines, fakeHost(dir, false), `{"path":"a.txt"}`, false)
if got.Error != "" || !strings.Contains(got.Text, "3 lines") {
t.Fatalf("got %+v", got)
}
if got := run(t, countLines, fakeHost(dir, false), `{"path":"missing.txt"}`, false); got.Error == "" {
t.Fatal("a missing file should fail")
}
}
func TestSaveSummaryNeedsEdits(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := run(t, saveSummary, fakeHost(dir, false), `{"path":"a.txt"}`, false); got.Error == "" {
t.Fatal("saving without edits allowed should fail")
}
got := run(t, saveSummary, fakeHost(dir, true), `{"path":"a.txt"}`, true)
if got.Error != "" {
t.Fatalf("got %+v", got)
}
data, err := os.ReadFile(filepath.Join(dir, "a.txt.summary.txt"))
if err != nil || string(data) != "1 lines\n" {
t.Fatalf("summary = %q, %v", data, err)
}
}
@@ -0,0 +1,8 @@
{
"summary": "Counts the lines in a text file.",
"category": "Productivity",
"visibility": "store",
"organizations": [],
"support": "",
"price": "free"
}
@@ -0,0 +1,14 @@
{
"id": "[[.ID]]",
"name": "[[.Title]]",
"description": "Counts the lines in a text file and saves a summary next to it. Replace this with what your plugin does.",
"version": "0.1.0",
"min_app_version": "0.5.0",
"host_api": 2,
"color": "teal",
"permissions": ["files"],
"tools": [
{"name": "[[.Short]]_count_lines", "mutating": false},
{"name": "[[.Short]]_save_summary", "mutating": true}
]
}
@@ -0,0 +1,3 @@
first line
second line
third line
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
const testWorkspaceName = "Test folder"
// workspace answers host.workspace.* from one directory, as one shared
// folder the user owns.
func (h *testHost) workspace(method, workspace, key, prefix, src, data string) (any, error) {
if h.workspaceDir == "" {
return nil, errors.New("no shared folders in the test host; pass -workspace <dir> to simulate one")
}
keyPath := func(key string) (string, error) {
rel, ok := strings.CutPrefix(key, testWorkspaceID+"/files/")
if !ok || rel == "" {
return "", fmt.Errorf("%q is not a file in a shared folder (keys look like %s/files/<path>)", key, testWorkspaceID)
}
return h.workspaceFile(rel)
}
folder := func() error {
if workspace != testWorkspaceID && workspace != testWorkspaceName {
return fmt.Errorf("you are not a member of a shared folder %q", workspace)
}
return nil
}
switch method {
case "host.workspace.folders":
return []map[string]string{{"id": testWorkspaceID, "name": testWorkspaceName, "role": "owner"}}, nil
case "host.workspace.list":
if err := folder(); err != nil {
return nil, err
}
var out []map[string]any
err := filepath.WalkDir(h.workspaceDir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || len(out) >= 1000 {
return err
}
rel := filepath.ToSlash(mustRel(h.workspaceDir, p))
if !strings.HasPrefix(rel, prefix) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
etag, err := fileETag(p)
if err != nil {
return err
}
out = append(out, map[string]any{"key": testWorkspaceID + "/files/" + rel, "size": info.Size(), "etag": etag, "modified": info.ModTime().UTC()})
return nil
})
return out, err
case "host.workspace.fetch":
file, err := keyPath(key)
if err != nil {
return nil, err
}
etag, err := fileETag(file)
if err != nil {
return nil, fmt.Errorf("%s does not exist", key)
}
// Like Cletus: a cached copy in the working folder's scratch area,
// which host.files.resolve accepts.
dst := filepath.Join(h.filesDir, ".cletus", "tmp", "workspace", filepath.FromSlash(strings.TrimPrefix(key, testWorkspaceID+"/files/")))
if err := copyFile(file, dst); err != nil {
return nil, err
}
return map[string]string{"path": dst, "etag": etag}, nil
case "host.workspace.read":
file, err := keyPath(key)
if err != nil {
return nil, err
}
info, err := os.Stat(file)
if err != nil {
return nil, fmt.Errorf("%s does not exist", key)
}
if info.Size() > 2<<20 {
return nil, fmt.Errorf("%s is larger than 2 MiB; use host.workspace.fetch", key)
}
body, err := os.ReadFile(file)
if err != nil {
return nil, err
}
etag, _ := fileETag(file)
return map[string]any{"data": base64.StdEncoding.EncodeToString(body), "etag": etag, "size": len(body)}, nil
case "host.workspace.put":
if err := folder(); err != nil {
return nil, err
}
if key == "" || strings.Contains(key, "..") || strings.HasPrefix(key, "/") || strings.Contains(key, "//") {
return nil, fmt.Errorf("key %q must be a relative path with no empty or .. segments", key)
}
dst, err := h.workspaceFile(key)
if err != nil {
return nil, err
}
var body []byte
if src != "" {
resolved, err := h.resolve(src)
if err != nil {
return nil, err
}
if body, err = os.ReadFile(resolved); err != nil {
return nil, err
}
} else if body, err = base64.StdEncoding.DecodeString(data); err != nil {
return nil, errors.New("data must be base64")
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(dst, body, 0o644); err != nil {
return nil, err
}
etag, _ := fileETag(dst)
return map[string]any{"key": testWorkspaceID + "/files/" + key, "size": len(body), "etag": etag}, nil
case "host.workspace.delete":
file, err := keyPath(key)
if err != nil {
return nil, err
}
if err := os.Remove(file); err != nil && !os.IsNotExist(err) {
return nil, err
}
return map[string]string{"key": key}, nil
case "host.workspace.create":
return nil, errors.New("the test host has one shared folder; it can't create another")
}
return nil, fmt.Errorf("unknown plugin host operation %q", method)
}
func (h *testHost) workspaceFile(rel string) (string, error) {
file := filepath.Join(h.workspaceDir, filepath.FromSlash(path.Clean("/"+rel)))
if !inside(h.workspaceDir, file) {
return "", errors.New("the key is outside the shared folder")
}
return file, nil
}
func mustRel(root, p string) string {
rel, _ := filepath.Rel(root, p)
return rel
}
func fileETag(p string) (string, error) {
f, err := os.Open(p)
if err != nil {
return "", err
}
defer f.Close()
sum := sha256.New()
if _, err := io.Copy(sum, f); err != nil {
return "", err
}
return hex.EncodeToString(sum.Sum(nil))[:32], nil
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
+3
View File
@@ -0,0 +1,3 @@
module git.eidonix.com/eidonix/cletus-plugins/sdk
go 1.24
+296
View File
@@ -0,0 +1,296 @@
// Package manifest reads and checks plugin.json, and holds the permission
// table: which host methods each permission unlocks (docs/specification.md,
// "Permissions"; docs/host-api.md).
package manifest
import (
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"sort"
"strings"
)
// HostAPI is the host API version this SDK targets.
const HostAPI = 2
// Plugin is plugin.json.
type Plugin struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
MinAppVersion string `json:"min_app_version"`
HostAPI int `json:"host_api"`
Color string `json:"color,omitempty"`
Permissions []string `json:"permissions,omitempty"`
FileTypes []string `json:"file_types,omitempty"`
OpensFolders bool `json:"opens_folders,omitempty"`
Tools []Tool `json:"tools,omitempty"`
Provides map[string]Service `json:"provides,omitempty"`
Uses []string `json:"uses,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
// Fields a publisher may not set, or that wait for a later release.
UI json.RawMessage `json:"ui,omitempty"`
UIHostAPI json.RawMessage `json:"ui_host_api,omitempty"`
Free *bool `json:"free,omitempty"`
Capability string `json:"capability,omitempty"`
HostSeams []string `json:"host_seams,omitempty"`
}
// Tool is one entry of plugin.json "tools".
type Tool struct {
Name string `json:"name"`
Mutating bool `json:"mutating"`
RequiresApproval bool `json:"requires_approval,omitempty"`
}
// Service is one entry of plugin.json "provides".
type Service struct {
Methods map[string]Method `json:"methods"`
}
// Method is one method of a provided service.
type Method struct {
Description string `json:"description"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Mutating bool `json:"mutating,omitempty"`
Long bool `json:"long,omitempty"`
}
// Read reads and decodes plugin.json, refusing unknown fields.
func Read(path string) (*Plugin, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
var p Plugin
if err := dec.Decode(&p); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &p, nil
}
// Always are the host methods every plugin may call.
var Always = []string{
"host.auth.has_capability",
"host.data.publish", "host.data.revoke", "host.data.leases", "host.data.remove",
"host.event", "host.screen.open",
"host.dialog.open", "host.dialog.folder", "host.dialog.save", "host.dialog.message",
"host.plugin.call",
}
// Permission is one permission a plugin may declare.
type Permission struct {
Words string // shown to the admin and the user
Methods []string // host methods it unlocks (empty: enforced by review)
}
// Permissions are the fixed permission tokens. net:<domain> is separate.
var Permissions = map[string]Permission{
"files": {"Open and save files you choose", []string{"host.files.resolve", "host.files.can_write"}},
"files.computer": {"Read and write other files on this computer", nil},
"workspace.read": {"Read files in shared folders you're a member of", []string{"host.workspace.folders", "host.workspace.list", "host.workspace.fetch", "host.workspace.read"}},
"workspace.write": {"Add, change and delete files in shared folders", []string{"host.workspace.put", "host.workspace.delete", "host.workspace.create"}},
"agents": {"Run Cletus in the background", []string{"host.agent.start", "host.agent.status", "host.agent.cancel"}},
"chat.handoff": {"Send work to chat", nil},
"identity": {"See your name and organization", []string{"host.auth.whoami"}},
"net.configured": {"Connect to devices and servers you set up in this plugin", nil},
"processes": {"Run programs on this computer", nil},
}
// NetPrefix starts a net:<domain> permission.
const NetPrefix = "net:"
// MaxNetDomains bounds the net:<domain> permissions.
const MaxNetDomains = 16
var (
idRE = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}\.[a-z][a-z0-9-]{0,47}$`)
semverRE = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`)
toolRE = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
serviceRE = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$`)
methodRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
useRE = regexp.MustCompile(`^[a-z0-9.-]+:[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*\.([a-z][a-z0-9_]*|\*)$`)
domainRE = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
extRE = regexp.MustCompile(`^\.[a-z0-9]{1,16}$`)
)
var colors = map[string]bool{"": true, "red": true, "orange": true, "amber": true, "yellow": true, "green": true,
"teal": true, "blue": true, "indigo": true, "violet": true, "pink": true, "grey": true}
// Publisher returns the publisher half of the id.
func (p *Plugin) Publisher() string { publisher, _, _ := strings.Cut(p.ID, "."); return publisher }
// Validate checks the manifest. Every problem is a plain sentence.
func (p *Plugin) Validate() []error {
var errs []error
add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
if !idRE.MatchString(p.ID) {
add("id %q must be <publisher>.<name>: lowercase letters, digits and hyphens, starting with a letter", p.ID)
}
if strings.TrimSpace(p.Name) == "" {
add("name is empty")
}
if strings.TrimSpace(p.Description) == "" {
add("description is empty")
}
if !semverRE.MatchString(p.Version) {
add("version %q must be a version like 1.2.0", p.Version)
}
if !semverRE.MatchString(p.MinAppVersion) {
add("min_app_version %q must be a version like 0.5.0", p.MinAppVersion)
}
if p.HostAPI != HostAPI {
add("host_api must be %d", HostAPI)
}
if !colors[p.Color] {
add("color %q is not one of the Cletus colours", p.Color)
}
if p.Free != nil || p.Capability != "" {
add("free and capability are for Eidonix plugins; organizations turn your plugin on instead")
}
if len(p.HostSeams) > 0 {
add("host_seams is for Eidonix plugins; use permissions")
}
if len(p.UI) > 0 || len(p.UIHostAPI) > 0 {
add("screens (ui, ui_host_api) aren't available yet; deliver the feature as chat tools")
}
if len(p.FileTypes) > 0 {
add("file_types need a screen, which isn't available yet")
}
seen := map[string]bool{}
nets := 0
for _, perm := range p.Permissions {
if seen[perm] {
add("permission %q is listed twice", perm)
}
seen[perm] = true
if domain, ok := strings.CutPrefix(perm, NetPrefix); ok {
nets++
if !domainRE.MatchString(domain) {
add("%q: a net permission names one host, like net:api.example.com (no wildcards, ports or IP addresses)", perm)
}
continue
}
if _, ok := Permissions[perm]; !ok {
add("unknown permission %q; see docs/specification.md, Permissions", perm)
}
}
if nets > MaxNetDomains {
add("at most %d net:<domain> permissions", MaxNetDomains)
}
if seen["chat.handoff"] {
add("chat.handoff is for screens, which aren't available yet")
}
tools := map[string]bool{}
for _, t := range p.Tools {
if !toolRE.MatchString(t.Name) {
add("tool name %q must be lowercase letters, digits and underscores", t.Name)
}
if tools[t.Name] {
add("tool %q is listed twice", t.Name)
}
tools[t.Name] = true
}
for name, svc := range p.Provides {
if !serviceRE.MatchString(name) {
add("service name %q must be lowercase, optionally dotted", name)
}
if name == "screen" {
add("the screen service needs a screen, which isn't available yet")
}
for method, m := range svc.Methods {
if !methodRE.MatchString(method) {
add("method %s.%s: names are lowercase letters, digits and underscores", name, method)
}
for label, schema := range map[string]json.RawMessage{"params": m.Params, "result": m.Result} {
var v map[string]any
if len(schema) == 0 || json.Unmarshal(schema, &v) != nil {
add("method %s.%s: %s must be a JSON schema object", name, method, label)
} else if len(schema) > 16<<10 {
add("method %s.%s: %s is larger than 16 KiB", name, method, label)
} else if strings.Contains(string(schema), `"$ref":"http`) || strings.Contains(string(schema), `"$ref": "http`) {
add("method %s.%s: %s may not use a remote $ref", name, method, label)
}
}
}
}
for _, use := range p.Uses {
if !useRE.MatchString(use) {
add("use %q must be <plugin>:<service>.<method> or <plugin>:<service>.*", use)
} else if strings.HasPrefix(use, p.ID+":") {
add("a plugin can't use its own service (%s)", use)
}
}
for _, ext := range p.FileTypes {
if !extRE.MatchString(ext) {
add("file type %q must look like .ext", ext)
}
}
return errs
}
// Allowed reports whether the plugin may call method, and if not, the
// permission that would allow it.
func (p *Plugin) Allowed(method string) (bool, string) {
for _, m := range Always {
if m == method {
return true, ""
}
}
for _, perm := range p.Permissions {
for _, m := range Permissions[perm].Methods {
if m == method {
return true, ""
}
}
}
for perm, def := range Permissions {
for _, m := range def.Methods {
if m == method {
return false, perm
}
}
}
return false, ""
}
// Has reports whether the plugin declares perm.
func (p *Plugin) Has(perm string) bool {
for _, have := range p.Permissions {
if have == perm {
return true
}
}
return false
}
// HasNet reports whether any net permission is declared.
func (p *Plugin) HasNet() bool {
for _, have := range p.Permissions {
if have == "net.configured" || strings.HasPrefix(have, NetPrefix) {
return true
}
}
return false
}
// UsedMethods returns, sorted, the host methods the declared permissions unlock.
func (p *Plugin) UsedMethods() []string {
var out []string
for _, perm := range p.Permissions {
out = append(out, Permissions[perm].Methods...)
}
sort.Strings(out)
return out
}
// ErrNoManifest is returned when there is no plugin.json.
var ErrNoManifest = errors.New("no plugin.json here; run cletus-plugin in the plugin's folder")
+61
View File
@@ -0,0 +1,61 @@
package manifest
import (
"strings"
"testing"
)
func valid() *Plugin {
return &Plugin{ID: "acme.gauge-check", Name: "Gauge Check", Description: "Checks gauges.", Version: "1.0.0",
MinAppVersion: "0.5.0", HostAPI: HostAPI, Permissions: []string{"files", "net:api.example.com"},
Tools: []Tool{{Name: "gauge_check_run"}}}
}
func TestValidManifest(t *testing.T) {
if errs := valid().Validate(); len(errs) != 0 {
t.Fatalf("valid manifest refused: %v", errs)
}
}
func TestRefusals(t *testing.T) {
cases := map[string]func(*Plugin){
"must be <publisher>.<name>": func(p *Plugin) { p.ID = "gauge" },
"version": func(p *Plugin) { p.Version = "1.0" },
"host_api": func(p *Plugin) { p.HostAPI = 1 },
"unknown permission": func(p *Plugin) { p.Permissions = []string{"workspace"} },
"no wildcards": func(p *Plugin) { p.Permissions = []string{"net:10.0.0.1"} },
"Eidonix plugins": func(p *Plugin) { p.Capability = "quoting" },
"aren't available yet": func(p *Plugin) { p.UI = []byte(`{}`) },
"listed twice": func(p *Plugin) { p.Tools = append(p.Tools, Tool{Name: "gauge_check_run"}) },
"its own service": func(p *Plugin) { p.Uses = []string{"acme.gauge-check:gauge.check"} },
}
for want, change := range cases {
p := valid()
change(p)
errs := p.Validate()
found := false
for _, err := range errs {
found = found || strings.Contains(err.Error(), want)
}
if !found {
t.Errorf("%s: got %v", want, errs)
}
}
}
func TestAllowed(t *testing.T) {
p := valid()
if ok, _ := p.Allowed("host.files.resolve"); !ok {
t.Fatal("files permission did not allow host.files.resolve")
}
if ok, _ := p.Allowed("host.event"); !ok {
t.Fatal("host.event should always be allowed")
}
ok, need := p.Allowed("host.workspace.list")
if ok || need != "workspace.read" {
t.Fatalf("workspace.list = %v, %q", ok, need)
}
if ok, need := p.Allowed("host.code.llm.start"); ok || need != "" {
t.Fatalf("first-party method = %v, %q", ok, need)
}
}
+90
View File
@@ -0,0 +1,90 @@
package pluginsdk
import (
"context"
"encoding/json"
"fmt"
"time"
)
// Host makes calls to Cletus on behalf of the request being handled. Use
// it only inside the handler it was passed to.
type Host struct {
call func(ctx context.Context, method string, params any) (json.RawMessage, error)
}
// NewTestHost returns a Host whose calls are answered by fn, for unit tests
// of handlers. fn receives the method and the params as JSON.
func NewTestHost(fn func(method string, params json.RawMessage) (any, error)) *Host {
return &Host{call: func(_ context.Context, method string, params any) (json.RawMessage, error) {
raw, err := json.Marshal(params)
if err != nil {
return nil, err
}
result, err := fn(method, raw)
if err != nil {
return nil, err
}
return json.Marshal(result)
}}
}
// Call makes any host call in docs/host-api.md and decodes its result into
// out (which may be nil).
func (h *Host) Call(ctx context.Context, method string, params, out any) error {
if params == nil {
params = struct{}{}
}
raw, err := h.call(ctx, method, params)
if err != nil {
return err
}
if out == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s: unexpected result: %w", method, err)
}
return nil
}
// ResolvePath returns the real path of a file the user chose
// (host.files.resolve; needs the files permission). Resolve every path
// before reading it.
func (h *Host) ResolvePath(ctx context.Context, path string) (string, error) {
var resolved string
err := h.Call(ctx, "host.files.resolve", map[string]string{"path": path}, &resolved)
return resolved, err
}
// CanWrite reports whether the user's current rules allow writing path
// (host.files.can_write; needs the files permission).
func (h *Host) CanWrite(ctx context.Context, path string) (bool, error) {
var allowed bool
err := h.Call(ctx, "host.files.can_write", map[string]string{"path": path}, &allowed)
return allowed, err
}
// Event sends an event to the plugin's screen. name must start with the
// plugin id and a colon ("acme.gauge-check:progress").
func (h *Host) Event(ctx context.Context, name string, args ...any) error {
if args == nil {
args = []any{}
}
return h.Call(ctx, "host.event", map[string]any{"name": name, "args": args}, nil)
}
// Published is a link to a file in the data folder that the screen can fetch.
type Published struct {
Token string `json:"token"`
URL string `json:"url"`
ExpiresAt string `json:"expiresAt"`
}
// Publish makes a file in the data folder fetchable by the screen for ttl
// (1 second to 1 hour). The file must not change while published.
func (h *Host) Publish(ctx context.Context, path, contentType string, ttl time.Duration) (Published, error) {
var out Published
err := h.Call(ctx, "host.data.publish", map[string]any{"path": path, "contentType": contentType, "ttl": int(ttl / time.Second)}, &out)
return out, err
}
+337
View File
@@ -0,0 +1,337 @@
// Package pluginsdk is the worker side of a Cletus plugin: the message
// protocol, chat tools, screen and service handlers, and calls to the host.
// See docs/specification.md and docs/host-api.md.
package pluginsdk
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
)
// APIVersion is the worker protocol version the host expects at handshake.
const APIVersion = 1
// MaxMessageBytes is the largest message (one line) either side may send.
const MaxMessageBytes = 64 << 20
// Func handles one screen (ui.*) request. params is the JSON array of the
// screen's arguments; decode it with Args.
type Func func(ctx context.Context, h *Host, params json.RawMessage) (any, error)
// ServiceFunc handles one service method another plugin calls.
type ServiceFunc func(ctx context.Context, h *Host, call ServiceCall) (any, error)
// ServiceCall is a service request the host routed from another plugin.
type ServiceCall struct {
Caller string `json:"caller"`
Mutating bool `json:"mutating"`
Args json.RawMessage `json:"args"`
}
// Handlers is everything a worker answers.
type Handlers struct {
// Tools are the chat tools. Each must also be listed in plugin.json.
Tools []Tool
// UI maps a screen method name ("check", answered as ui.check) to its handler.
UI map[string]Func
// Services maps "<service>.<method>" ("gauge.check") to its handler.
// Each must be declared under "provides" in plugin.json.
Services map[string]ServiceFunc
// Busy reports whether work is running that must not be interrupted.
// Nil means never busy.
Busy func() bool
}
// ID returns the plugin id the host started the worker as.
func ID() string { return os.Getenv("CLETUS_PLUGIN_ID") }
// DataDir returns the worker's data folder for the signed-in account. It is
// also the worker's working directory. Empty when not started by a host.
func DataDir() string { return os.Getenv("CLETUS_PLUGIN_DATA") }
// Serve runs the worker on standard input and output until the host shuts
// it down or closes the pipe. Standard output belongs to the protocol, so
// Serve points os.Stdout at standard error: a stray fmt.Println lands in
// the worker log instead of breaking the connection.
func Serve(h Handlers) error {
out := os.Stdout
os.Stdout = os.Stderr
return ServeIO(context.Background(), os.Stdin, out, h)
}
type message struct {
ID string `json:"id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *wireError `json:"error,omitempty"`
}
type wireError struct {
Message string `json:"message"`
}
type server struct {
handlers Handlers
tools map[string]Tool
out io.Writer
writeMu sync.Mutex
mu sync.Mutex
active map[string]context.CancelFunc
pending map[string]chan message
next int
wg sync.WaitGroup
}
// ServeIO runs the worker on the given streams. It returns when ctx ends,
// the input closes, or the host sends plugin.shutdown.
func ServeIO(ctx context.Context, in io.Reader, out io.Writer, h Handlers) error {
s := &server{handlers: h, tools: map[string]Tool{}, out: out,
active: map[string]context.CancelFunc{}, pending: map[string]chan message{}}
for _, t := range h.Tools {
if _, dup := s.tools[t.Name]; dup {
return fmt.Errorf("tool %q is registered twice", t.Name)
}
s.tools[t.Name] = t
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
defer s.wg.Wait()
defer s.cancelAll()
lines := make(chan []byte)
readErr := make(chan error, 1)
go func() {
reader := bufio.NewReaderSize(in, 64<<10)
for {
line, err := readLine(reader)
if len(line) > 0 {
select {
case lines <- line:
case <-ctx.Done():
return
}
}
if err != nil {
readErr <- err
return
}
}
}()
for {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-readErr:
if errors.Is(err, io.EOF) {
return nil
}
return err
case line := <-lines:
var m message
if err := json.Unmarshal(line, &m); err != nil {
return fmt.Errorf("invalid message from host: %w", err)
}
if stop := s.dispatch(ctx, m); stop {
return nil
}
}
}
}
func readLine(r *bufio.Reader) ([]byte, error) {
var line []byte
for {
chunk, err := r.ReadSlice('\n')
line = append(line, chunk...)
if len(line) > MaxMessageBytes+1 {
return nil, errors.New("message from host is too large")
}
if err == bufio.ErrBufferFull {
continue
}
return []byte(strings.TrimSpace(string(line))), err
}
}
// dispatch handles one incoming message and reports whether to stop.
func (s *server) dispatch(ctx context.Context, m message) bool {
switch {
case m.Method == "$/cancel":
var id string
if json.Unmarshal(m.Params, &id) == nil {
s.mu.Lock()
if cancel := s.active[id]; cancel != nil {
cancel()
}
s.mu.Unlock()
}
case m.Method == "plugin.shutdown":
s.reply(m.ID, map[string]bool{"ok": true}, nil)
return true
case m.Method != "":
if m.ID == "" {
return false // a notification this worker doesn't use
}
reqCtx, cancel := context.WithCancel(ctx)
s.mu.Lock()
s.active[m.ID] = cancel
s.mu.Unlock()
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer func() {
s.mu.Lock()
delete(s.active, m.ID)
s.mu.Unlock()
cancel()
}()
result, err := s.handle(reqCtx, m)
s.reply(m.ID, result, err)
}()
case m.ID != "":
s.mu.Lock()
ch := s.pending[m.ID]
delete(s.pending, m.ID)
s.mu.Unlock()
if ch != nil {
ch <- m
}
}
return false
}
func (s *server) handle(ctx context.Context, m message) (result any, err error) {
defer func() {
if r := recover(); r != nil {
result, err = nil, fmt.Errorf("the plugin failed while handling %s: %v", m.Method, r)
}
}()
h := &Host{call: func(ctx context.Context, method string, params any) (json.RawMessage, error) {
return s.callHost(ctx, m.ID, method, params)
}}
switch {
case m.Method == "plugin.handshake":
return map[string]int{"api_version": APIVersion}, nil
case m.Method == "plugin.busy":
return s.handlers.Busy != nil && s.handlers.Busy(), nil
case m.Method == "tools.list":
return s.listTools(), nil
case m.Method == "tools.invoke":
return s.invokeTool(ctx, h, m.Params)
case strings.HasPrefix(m.Method, "ui."):
fn := s.handlers.UI[strings.TrimPrefix(m.Method, "ui.")]
if fn == nil {
return nil, fmt.Errorf("unknown method %s", m.Method)
}
return fn(ctx, h, m.Params)
case strings.HasPrefix(m.Method, "svc."):
fn := s.handlers.Services[strings.TrimPrefix(m.Method, "svc.")]
if fn == nil {
return nil, fmt.Errorf("unknown method %s", m.Method)
}
var call ServiceCall
if err := json.Unmarshal(m.Params, &call); err != nil {
return nil, fmt.Errorf("invalid service request: %w", err)
}
return fn(ctx, h, call)
}
return nil, fmt.Errorf("unknown method %s", m.Method)
}
func (s *server) reply(id string, result any, err error) {
if id == "" {
return
}
m := message{ID: id}
if err != nil {
m.Error = &wireError{Message: err.Error()}
} else {
raw, marshalErr := json.Marshal(result)
if marshalErr != nil {
m.Error = &wireError{Message: "the plugin returned a result that is not JSON: " + marshalErr.Error()}
} else {
m.Result = raw
}
}
s.send(m)
}
func (s *server) send(m message) {
data, err := json.Marshal(m)
if err != nil || len(data) > MaxMessageBytes {
if m.ID != "" && m.Method == "" {
data, _ = json.Marshal(message{ID: m.ID, Error: &wireError{Message: "the plugin's reply is too large"}})
} else {
return
}
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, _ = s.out.Write(append(data, '\n'))
}
func (s *server) callHost(ctx context.Context, parent, method string, params any) (json.RawMessage, error) {
raw, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("%s: %w", method, err)
}
ch := make(chan message, 1)
s.mu.Lock()
s.next++
id := fmt.Sprintf("w%d", s.next)
s.pending[id] = ch
s.mu.Unlock()
s.send(message{ID: id, ParentID: parent, Method: method, Params: raw})
select {
case reply := <-ch:
if reply.Error != nil {
return nil, errors.New(reply.Error.Message)
}
return reply.Result, nil
case <-ctx.Done():
s.mu.Lock()
delete(s.pending, id)
s.mu.Unlock()
cancel, _ := json.Marshal(id)
s.send(message{Method: "$/cancel", Params: cancel})
return nil, ctx.Err()
}
}
func (s *server) cancelAll() {
s.mu.Lock()
defer s.mu.Unlock()
for _, cancel := range s.active {
cancel()
}
}
// Args decodes a screen request's argument array into targets, in order.
// Missing trailing arguments leave their targets unchanged.
func Args(params json.RawMessage, targets ...any) error {
var items []json.RawMessage
if len(params) > 0 && string(params) != "null" {
if err := json.Unmarshal(params, &items); err != nil {
return fmt.Errorf("screen arguments must be a JSON array: %w", err)
}
}
for i, target := range targets {
if i >= len(items) {
break
}
if err := json.Unmarshal(items[i], target); err != nil {
return fmt.Errorf("argument %d: %w", i+1, err)
}
}
return nil
}
+168
View File
@@ -0,0 +1,168 @@
package pluginsdk
import (
"bufio"
"context"
"encoding/json"
"io"
"strings"
"testing"
"time"
)
// pipeHost drives a worker over in-memory pipes, as Cletus does.
type pipeHost struct {
t *testing.T
toW *io.PipeWriter
fromW *bufio.Scanner
done chan error
cancel context.CancelFunc
}
func startWorker(t *testing.T, h Handlers) *pipeHost {
t.Helper()
inR, inW := io.Pipe()
outR, outW := io.Pipe()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- ServeIO(ctx, inR, outW, h); outW.Close() }()
sc := bufio.NewScanner(outR)
sc.Buffer(make([]byte, 1<<20), MaxMessageBytes)
p := &pipeHost{t: t, toW: inW, fromW: sc, done: done, cancel: cancel}
t.Cleanup(func() { cancel(); inW.Close() })
return p
}
func (p *pipeHost) send(m message) {
p.t.Helper()
data, _ := json.Marshal(m)
if _, err := p.toW.Write(append(data, '\n')); err != nil {
p.t.Fatal(err)
}
}
func (p *pipeHost) read() message {
p.t.Helper()
if !p.fromW.Scan() {
p.t.Fatalf("worker closed: %v", p.fromW.Err())
}
var m message
if err := json.Unmarshal(p.fromW.Bytes(), &m); err != nil {
p.t.Fatal(err)
}
return m
}
func TestHandshakeAndShutdown(t *testing.T) {
p := startWorker(t, Handlers{})
p.send(message{ID: "1", Method: "plugin.handshake"})
if got := p.read(); string(got.Result) != `{"api_version":1}` {
t.Fatalf("handshake = %s", got.Result)
}
p.send(message{ID: "2", Method: "plugin.shutdown"})
if got := p.read(); got.ID != "2" || got.Error != nil {
t.Fatalf("shutdown reply = %+v", got)
}
select {
case err := <-p.done:
if err != nil {
t.Fatalf("ServeIO = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("worker did not stop after plugin.shutdown")
}
}
func TestToolWithHostCall(t *testing.T) {
tool := Tool{Name: "demo_read", Description: "Reads a file.", Run: func(ctx context.Context, h *Host, call ToolCall) ToolResult {
var args struct{ Path string }
if err := call.Decode(&args); err != nil {
return Fail("%v", err)
}
path, err := h.ResolvePath(ctx, args.Path)
if err != nil {
return Fail("%v", err)
}
return Text("resolved %s", path)
}}
p := startWorker(t, Handlers{Tools: []Tool{tool}})
p.send(message{ID: "1", Method: "tools.list"})
var list []toolDescriptor
if err := json.Unmarshal(p.read().Result, &list); err != nil || len(list) != 1 || list[0].Def.Function.Name != "demo_read" {
t.Fatalf("tools.list = %+v, %v", list, err)
}
params, _ := json.Marshal(toolInvocation{Name: "demo_read", Args: `{"Path":"a.txt"}`})
p.send(message{ID: "2", Method: "tools.invoke", Params: params})
call := p.read()
if call.Method != "host.files.resolve" || call.ParentID != "2" || !strings.Contains(string(call.Params), "a.txt") {
t.Fatalf("host call = %+v", call)
}
p.send(message{ID: call.ID, Result: json.RawMessage(`"/work/a.txt"`)})
var result ToolResult
if err := json.Unmarshal(p.read().Result, &result); err != nil || result.Text != "resolved /work/a.txt" {
t.Fatalf("result = %+v, %v", result, err)
}
}
func TestCancelStopsHandler(t *testing.T) {
started := make(chan struct{})
p := startWorker(t, Handlers{UI: map[string]Func{"wait": func(ctx context.Context, h *Host, params json.RawMessage) (any, error) {
close(started)
<-ctx.Done()
return nil, ctx.Err()
}}})
p.send(message{ID: "9", Method: "ui.wait", Params: json.RawMessage(`[]`)})
<-started
cancelID, _ := json.Marshal("9")
p.send(message{Method: "$/cancel", Params: cancelID})
if got := p.read(); got.ID != "9" || got.Error == nil {
t.Fatalf("cancelled reply = %+v", got)
}
}
func TestPanicBecomesError(t *testing.T) {
p := startWorker(t, Handlers{UI: map[string]Func{"boom": func(context.Context, *Host, json.RawMessage) (any, error) {
panic("bad input")
}}})
p.send(message{ID: "1", Method: "ui.boom"})
if got := p.read(); got.Error == nil || !strings.Contains(got.Error.Message, "bad input") {
t.Fatalf("reply = %+v", got)
}
}
func TestServiceAndUnknownMethod(t *testing.T) {
p := startWorker(t, Handlers{Services: map[string]ServiceFunc{"gauge.check": func(_ context.Context, _ *Host, call ServiceCall) (any, error) {
return map[string]string{"caller": call.Caller}, nil
}}})
p.send(message{ID: "1", Method: "svc.gauge.check", Params: json.RawMessage(`{"caller":"acme.other","args":{}}`)})
if got := p.read(); string(got.Result) != `{"caller":"acme.other"}` {
t.Fatalf("service = %+v", got)
}
p.send(message{ID: "2", Method: "nope"})
if got := p.read(); got.Error == nil {
t.Fatalf("unknown method answered %+v", got)
}
}
func TestArgs(t *testing.T) {
var name string
var count int
if err := Args(json.RawMessage(`["x", 3]`), &name, &count); err != nil || name != "x" || count != 3 {
t.Fatalf("Args = %q %d %v", name, count, err)
}
if err := Args(json.RawMessage(`{"a":1}`), &name); err == nil {
t.Fatal("Args accepted an object")
}
}
func TestNewTestHost(t *testing.T) {
h := NewTestHost(func(method string, params json.RawMessage) (any, error) {
return method == "host.files.can_write", nil
})
ok, err := h.CanWrite(context.Background(), "x")
if err != nil || !ok {
t.Fatalf("CanWrite = %v %v", ok, err)
}
}
+116
View File
@@ -0,0 +1,116 @@
package pluginsdk
import (
"context"
"encoding/json"
"fmt"
"sort"
)
// Tool is a chat tool: a function Cletus's AI can call. Name, Mutating and
// RequiresApproval must match the plugin's entry in plugin.json "tools".
type Tool struct {
Name string
Description string
// Parameters is the JSON schema of the arguments (an object schema).
Parameters map[string]any
// Mutating is true if the tool writes anything anywhere.
Mutating bool
// RequiresApproval makes Cletus ask the user before every call.
RequiresApproval bool
Run func(ctx context.Context, h *Host, call ToolCall) ToolResult
}
// ToolCall is one call of a chat tool.
type ToolCall struct {
// Args is the JSON object of arguments the AI sent.
Args json.RawMessage
// WorkingDir is the user's working folder ("" when none is open).
WorkingDir string
// EditMode is false when the user has Cletus read-only: a mutating tool
// must check Host.CanWrite before each write and refuse when it's false.
EditMode bool
}
// Decode decodes the arguments into v.
func (c ToolCall) Decode(v any) error {
if len(c.Args) == 0 {
return json.Unmarshal([]byte("{}"), v)
}
if err := json.Unmarshal(c.Args, v); err != nil {
return fmt.Errorf("the arguments don't match the tool's parameters: %w", err)
}
return nil
}
// ToolResult is what the AI reads back. Set Text on success, Error on
// failure (a plain sentence with the next step). Images are optional
// (PNG or JPEG bytes).
type ToolResult struct {
Text string `json:"Text,omitempty"`
Error string `json:"Error,omitempty"`
Images [][]byte `json:"Images,omitempty"`
}
// Text returns a successful result.
func Text(format string, args ...any) ToolResult {
return ToolResult{Text: fmt.Sprintf(format, args...)}
}
// Fail returns a failed result.
func Fail(format string, args ...any) ToolResult {
return ToolResult{Error: fmt.Sprintf(format, args...)}
}
type toolDescriptor struct {
Def toolDef `json:"def"`
Mutating bool `json:"mutating"`
RequiresApproval bool `json:"requires_approval"`
}
type toolDef struct {
Type string `json:"type"`
Function functionDef `json:"function"`
}
type functionDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
func (s *server) listTools() []toolDescriptor {
out := make([]toolDescriptor, 0, len(s.tools))
for _, t := range s.tools {
params := t.Parameters
if params == nil {
params = map[string]any{"type": "object", "properties": map[string]any{}}
}
out = append(out, toolDescriptor{
Def: toolDef{Type: "function", Function: functionDef{Name: t.Name, Description: t.Description, Parameters: params}},
Mutating: t.Mutating,
RequiresApproval: t.RequiresApproval,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Def.Function.Name < out[j].Def.Function.Name })
return out
}
type toolInvocation struct {
Name string `json:"name"`
Args string `json:"args"`
WorkingDir string `json:"working_dir"`
EditMode bool `json:"edit_mode"`
}
func (s *server) invokeTool(ctx context.Context, h *Host, params json.RawMessage) (ToolResult, error) {
var inv toolInvocation
if err := json.Unmarshal(params, &inv); err != nil {
return ToolResult{}, fmt.Errorf("invalid tool call: %w", err)
}
t, ok := s.tools[inv.Name]
if !ok || t.Run == nil {
return Fail("This plugin has no tool named %q.", inv.Name), nil
}
return t.Run(ctx, h, ToolCall{Args: json.RawMessage(inv.Args), WorkingDir: inv.WorkingDir, EditMode: inv.EditMode}), nil
}