Cletus plugin kit: rules for coding agents, specification, host API, style guide, worker SDK and cletus-plugin tool
This commit is contained in:
commit
9b19d3a8be
41 files changed
+4121
No files matched your search
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,5 @@
|
||||
Cletus plugin kit
|
||||
Copyright 2026 Eidonix
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
Cletus and Eidonix are trademarks of Eidonix and are not licensed by it.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,5 @@
|
||||
Cletus plugin kit
|
||||
Copyright 2026 Eidonix
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
Cletus and Eidonix are trademarks of Eidonix and are not licensed by it.
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// goEnv is the environment every build and test runs with: no cgo, no
|
||||
// enclosing go.work, the plugin's vendor folder.
|
||||
func goEnv(goos, goarch string) []string {
|
||||
env := append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off")
|
||||
if goos != "" {
|
||||
env = append(env, "GOOS="+goos, "GOARCH="+goarch)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// goRun runs a go command in dir and returns its combined output.
|
||||
func goRun(dir string, env []string, args ...string) (string, error) {
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
err := cmd.Run()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
// buildWorker builds ./cmd/worker for this machine into a temporary folder.
|
||||
func buildWorker(dir string) (string, func(), error) {
|
||||
tmp, err := os.MkdirTemp("", "cletus-plugin-")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanup := func() { os.RemoveAll(tmp) }
|
||||
name := "worker"
|
||||
if runtime.GOOS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
bin := filepath.Join(tmp, name)
|
||||
if out, err := goRun(dir, goEnv("", ""), "build", "-trimpath", "-o", bin, "./cmd/worker"); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("the worker doesn't build:\n%s", out)
|
||||
}
|
||||
return bin, cleanup, nil
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
|
||||
)
|
||||
|
||||
// report collects the outcome of each check.
|
||||
type report struct {
|
||||
failures, warnings int
|
||||
}
|
||||
|
||||
func (r *report) ok(name string) { fmt.Printf("ok %s\n", name) }
|
||||
|
||||
func (r *report) fail(name, detail string) {
|
||||
r.failures++
|
||||
fmt.Printf("FAIL %s\n%s", name, indent(detail))
|
||||
}
|
||||
|
||||
func (r *report) warn(name, detail string) {
|
||||
r.warnings++
|
||||
fmt.Printf("warn %s\n%s", name, indent(detail))
|
||||
}
|
||||
|
||||
func indent(s string) string {
|
||||
s = strings.TrimRight(s, "\n")
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return " " + strings.ReplaceAll(s, "\n", "\n ") + "\n"
|
||||
}
|
||||
|
||||
func runCheck(args []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("check takes no arguments; run it in the plugin's folder")
|
||||
}
|
||||
r := &report{}
|
||||
plugin, err := readPlugin(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if errs := plugin.Validate(); len(errs) > 0 {
|
||||
var b strings.Builder
|
||||
for _, e := range errs {
|
||||
fmt.Fprintln(&b, e)
|
||||
}
|
||||
r.fail("plugin.json", b.String())
|
||||
} else {
|
||||
r.ok("plugin.json")
|
||||
}
|
||||
checkFiles(r, plugin)
|
||||
own := ownGoFiles()
|
||||
checkSources(r, own)
|
||||
checkGoMod(r)
|
||||
if out, err := goRun(".", goEnv("", ""), "vet", "./..."); err != nil {
|
||||
r.fail("go vet", out)
|
||||
} else {
|
||||
r.ok("go vet")
|
||||
}
|
||||
checkImports(r, plugin)
|
||||
checkDependencies(r)
|
||||
for _, target := range [][2]string{{"darwin", "arm64"}, {"windows", "amd64"}} {
|
||||
name := "build " + target[0] + "/" + target[1]
|
||||
if out, err := goRun(".", goEnv(target[0], target[1]), "build", "-trimpath", "-o", os.DevNull, "./cmd/worker"); err != nil {
|
||||
r.fail(name, out)
|
||||
} else {
|
||||
r.ok(name)
|
||||
}
|
||||
}
|
||||
if out, err := goRun(".", goEnv("", ""), "test", "-count=1", "./..."); err != nil {
|
||||
r.fail("go test", out)
|
||||
} else {
|
||||
r.ok("go test")
|
||||
}
|
||||
optionalTool(r, "staticcheck", "./...")
|
||||
optionalTool(r, "govulncheck", "./...")
|
||||
checkRuntime(r, plugin)
|
||||
|
||||
fmt.Println()
|
||||
switch {
|
||||
case r.failures > 0:
|
||||
return fmt.Errorf("%d check(s) failed, %d warning(s)", r.failures, r.warnings)
|
||||
case r.warnings > 0:
|
||||
fmt.Printf("All checks passed with %d warning(s). Explain each in docs/plan.md; reviewers read them.\n", r.warnings)
|
||||
default:
|
||||
fmt.Println("All checks passed.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkFiles(r *report, plugin *manifest.Plugin) {
|
||||
var problems []string
|
||||
changelog, err := os.ReadFile("CHANGELOG.md")
|
||||
if err != nil {
|
||||
problems = append(problems, "CHANGELOG.md is missing")
|
||||
} else if !regexp.MustCompile(`(?m)^#+\s*v?` + regexp.QuoteMeta(plugin.Version) + `\b`).Match(changelog) {
|
||||
problems = append(problems, "CHANGELOG.md has no heading for version "+plugin.Version)
|
||||
}
|
||||
for _, path := range []string{"docs/plan.md", "docs/guide.md", "go.mod", "cmd/worker"} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
problems = append(problems, path+" is missing")
|
||||
}
|
||||
}
|
||||
if plan, err := os.ReadFile("docs/plan.md"); err == nil {
|
||||
for _, perm := range plugin.Permissions {
|
||||
if !strings.Contains(string(plan), perm) {
|
||||
problems = append(problems, "docs/plan.md doesn't explain the "+perm+" permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
r.fail("repository files", strings.Join(problems, "\n"))
|
||||
} else {
|
||||
r.ok("repository files")
|
||||
}
|
||||
}
|
||||
|
||||
// ownGoFiles lists the plugin's own Go files (not vendor/, not hidden folders).
|
||||
func ownGoFiles() []string {
|
||||
var files []string
|
||||
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() && path != "." && (d.Name() == "vendor" || strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !d.IsDir() && strings.HasSuffix(path, ".go") {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return files
|
||||
}
|
||||
|
||||
var (
|
||||
secretRE = regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|\bsk-[A-Za-z0-9_-]{20,}|\bghp_[A-Za-z0-9]{30,}|\bxox[abp]-[A-Za-z0-9-]{10,}`)
|
||||
fileOpRE = regexp.MustCompile(`\bos\.(Open|OpenFile|ReadFile|WriteFile|Create|Remove|RemoveAll|ReadDir|Rename|Mkdir|MkdirAll)\(`)
|
||||
linknameRE = regexp.MustCompile(`//go:linkname`)
|
||||
)
|
||||
|
||||
func checkSources(r *report, files []string) {
|
||||
var gofmtBad, cgo, secrets, linkname, fileOps []string
|
||||
if len(files) > 0 {
|
||||
out, _ := exec.Command("gofmt", append([]string{"-l"}, files...)...).Output()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line != "" {
|
||||
gofmtBad = append(gofmtBad, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
if d != nil && d.IsDir() && path != "." && strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil || info.Size() > 4<<20 {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
text := string(data)
|
||||
vendored := strings.HasPrefix(filepath.ToSlash(path), "vendor/")
|
||||
if strings.HasSuffix(path, ".go") && regexp.MustCompile(`(?m)^import\s+"C"`).MatchString(text) {
|
||||
cgo = append(cgo, path)
|
||||
}
|
||||
if !vendored && secretRE.MatchString(text) {
|
||||
secrets = append(secrets, path)
|
||||
}
|
||||
if strings.HasSuffix(path, ".go") && !vendored {
|
||||
if linknameRE.MatchString(text) {
|
||||
linkname = append(linkname, path)
|
||||
}
|
||||
if !strings.HasSuffix(path, "_test.go") && fileOpRE.MatchString(text) {
|
||||
fileOps = append(fileOps, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if len(gofmtBad) > 0 {
|
||||
r.fail("gofmt", "not formatted (run gofmt -w):\n"+strings.Join(gofmtBad, "\n"))
|
||||
} else {
|
||||
r.ok("gofmt")
|
||||
}
|
||||
if len(cgo) > 0 {
|
||||
r.fail("no cgo", "these files use cgo, which Cletus plugins can't:\n"+strings.Join(cgo, "\n"))
|
||||
} else {
|
||||
r.ok("no cgo")
|
||||
}
|
||||
if len(secrets) > 0 {
|
||||
r.fail("no secrets", "these files look like they hold a key or token:\n"+strings.Join(secrets, "\n"))
|
||||
} else {
|
||||
r.ok("no secrets")
|
||||
}
|
||||
if len(linkname) > 0 {
|
||||
r.fail("no go:linkname", strings.Join(linkname, "\n"))
|
||||
}
|
||||
if len(fileOps) > 0 {
|
||||
r.warn("direct file access", "these files open, write or delete files directly. Each path must come from\nhost.files.resolve, a dialog, or pluginsdk.DataDir(); anything else needs files.computer:\n"+strings.Join(fileOps, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func checkGoMod(r *report) {
|
||||
data, err := os.ReadFile("go.mod")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text := string(data)
|
||||
var problems []string
|
||||
if regexp.MustCompile(`(?m)^\s*replace\b|=>`).MatchString(text) {
|
||||
problems = append(problems, "go.mod has replace directives; the store builds only from go.mod and vendor/")
|
||||
}
|
||||
if strings.Contains(text, "require") {
|
||||
if _, err := os.Stat("vendor/modules.txt"); err != nil {
|
||||
problems = append(problems, "vendor/ is missing; run go mod vendor and commit it")
|
||||
} else if out, err := goRun(".", goEnv("", ""), "build", "-mod=vendor", "-o", os.DevNull, "./cmd/worker"); err != nil {
|
||||
problems = append(problems, "vendor/ doesn't match go.mod; run go mod vendor:\n"+out)
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
r.fail("go.mod and vendor", strings.Join(problems, "\n"))
|
||||
} else {
|
||||
r.ok("go.mod and vendor")
|
||||
}
|
||||
}
|
||||
|
||||
type goPackage struct {
|
||||
ImportPath string
|
||||
Imports []string
|
||||
Standard bool
|
||||
Module *struct{ Path string }
|
||||
}
|
||||
|
||||
func listPackages(args ...string) ([]goPackage, error) {
|
||||
cmd := exec.Command("go", append([]string{"list", "-json"}, args...)...)
|
||||
cmd.Env = goEnv("", "")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return nil, fmt.Errorf("%s", exit.Stderr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var pkgs []goPackage
|
||||
dec := json.NewDecoder(strings.NewReader(string(out)))
|
||||
for {
|
||||
var p goPackage
|
||||
if err := dec.Decode(&p); err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
var netPackages = map[string]bool{"net": true, "net/http": true, "net/rpc": true, "net/smtp": true, "crypto/tls": true, "net/http/httputil": true}
|
||||
|
||||
func checkImports(r *report, plugin *manifest.Plugin) {
|
||||
own, err := listPackages("./...")
|
||||
if err != nil {
|
||||
r.fail("imports", err.Error())
|
||||
return
|
||||
}
|
||||
var failures, notes []string
|
||||
for _, p := range own {
|
||||
for _, imp := range p.Imports {
|
||||
switch {
|
||||
case imp == "plugin":
|
||||
failures = append(failures, p.ImportPath+" imports plugin (loading code at run time is not allowed)")
|
||||
case imp == "os/exec" && !plugin.Has("processes"):
|
||||
failures = append(failures, p.ImportPath+" imports os/exec but the plugin doesn't declare processes")
|
||||
case netPackages[imp] && !plugin.HasNet():
|
||||
failures = append(failures, p.ImportPath+" imports "+imp+" but the plugin declares no net permission")
|
||||
case imp == "unsafe" || imp == "reflect" || imp == "syscall" || strings.HasPrefix(imp, "golang.org/x/sys/"):
|
||||
notes = append(notes, p.ImportPath+" imports "+imp)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
r.fail("imports match permissions", strings.Join(failures, "\n"))
|
||||
} else {
|
||||
r.ok("imports match permissions")
|
||||
}
|
||||
if len(notes) > 0 {
|
||||
r.warn("low-level imports", "explain each in docs/plan.md:\n"+strings.Join(notes, "\n"))
|
||||
}
|
||||
deps, err := listPackages("-deps", "./cmd/worker")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var depNotes []string
|
||||
for _, p := range deps {
|
||||
if p.Standard || p.Module == nil || isOwnModule(p.Module.Path) {
|
||||
continue
|
||||
}
|
||||
for _, imp := range p.Imports {
|
||||
if imp == "os/exec" && !plugin.Has("processes") || netPackages[imp] && !plugin.HasNet() {
|
||||
depNotes = append(depNotes, p.ImportPath+" imports "+imp)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(depNotes) > 0 {
|
||||
sort.Strings(depNotes)
|
||||
r.warn("dependencies that can start programs or connect", "your plugin doesn't declare this; make sure these code paths are never used:\n"+strings.Join(depNotes, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func isOwnModule(path string) bool {
|
||||
data, err := os.ReadFile("go.mod")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
m := regexp.MustCompile(`(?m)^module\s+(\S+)`).FindStringSubmatch(string(data))
|
||||
return len(m) == 2 && m[1] == path
|
||||
}
|
||||
|
||||
func checkDependencies(r *report) {
|
||||
f, err := os.Open("vendor/modules.txt")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
var missing []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 3 || fields[0] != "#" || fields[2] == "=>" {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join("vendor", filepath.FromSlash(fields[1]))
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue // a module with no packages in the build
|
||||
}
|
||||
if !hasLicense(dir) {
|
||||
missing = append(missing, fields[1]+" "+fields[2])
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
r.warn("dependency licences", "no licence file found; the store must be able to confirm each licence allows distribution:\n"+strings.Join(missing, "\n"))
|
||||
} else {
|
||||
r.ok("dependency licences present")
|
||||
}
|
||||
}
|
||||
|
||||
func hasLicense(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := strings.ToUpper(e.Name())
|
||||
if strings.HasPrefix(name, "LICENSE") || strings.HasPrefix(name, "LICENCE") || strings.HasPrefix(name, "COPYING") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func optionalTool(r *report, tool string, args ...string) {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
fmt.Printf("skip %s (not installed; the store runs it)\n", tool)
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(tool, args...)
|
||||
cmd.Env = goEnv("", "")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
r.fail(tool, string(out))
|
||||
} else {
|
||||
r.ok(tool)
|
||||
}
|
||||
}
|
||||
|
||||
// checkRuntime starts the worker and compares its tools with plugin.json.
|
||||
func checkRuntime(r *report, plugin *manifest.Plugin) {
|
||||
wd, _ := os.Getwd()
|
||||
bin, cleanup, err := buildWorker(wd)
|
||||
if err != nil {
|
||||
r.fail("worker starts", err.Error())
|
||||
return
|
||||
}
|
||||
defer cleanup()
|
||||
tmp, err := os.MkdirTemp("", "cletus-plugin-check-")
|
||||
if err != nil {
|
||||
r.fail("worker starts", err.Error())
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
log := &syncBuffer{}
|
||||
host := &testHost{plugin: plugin, filesDir: tmp, dataDir: filepath.Join(tmp, "data"), capabilities: map[string]bool{}, log: log}
|
||||
if err := host.start(bin); err != nil {
|
||||
r.fail("worker starts", err.Error()+"\n"+log.String())
|
||||
return
|
||||
}
|
||||
defer host.stop()
|
||||
r.ok("worker starts and answers the handshake")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var tools []struct {
|
||||
Def struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"function"`
|
||||
} `json:"def"`
|
||||
Mutating bool `json:"mutating"`
|
||||
RequiresApproval bool `json:"requires_approval"`
|
||||
}
|
||||
if err := host.call(ctx, "tools.list", nil, &tools); err != nil {
|
||||
r.fail("tools match plugin.json", "tools.list: "+err.Error())
|
||||
return
|
||||
}
|
||||
declared := map[string]manifest.Tool{}
|
||||
for _, t := range plugin.Tools {
|
||||
declared[t.Name] = t
|
||||
}
|
||||
var problems []string
|
||||
for _, t := range tools {
|
||||
name := t.Def.Function.Name
|
||||
d, ok := declared[name]
|
||||
switch {
|
||||
case !ok:
|
||||
problems = append(problems, "the worker offers "+name+", which plugin.json doesn't list")
|
||||
case d.Mutating != t.Mutating || d.RequiresApproval != t.RequiresApproval:
|
||||
problems = append(problems, name+": mutating or requires_approval differs between plugin.json and the worker")
|
||||
}
|
||||
if strings.TrimSpace(t.Def.Function.Description) == "" {
|
||||
problems = append(problems, name+" has no description")
|
||||
}
|
||||
delete(declared, name)
|
||||
}
|
||||
for name := range declared {
|
||||
problems = append(problems, "plugin.json lists "+name+", which the worker doesn't offer")
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
r.fail("tools match plugin.json", strings.Join(problems, "\n"))
|
||||
} else {
|
||||
r.ok("tools match plugin.json")
|
||||
}
|
||||
}
|
||||
|
||||
// syncBuffer is a strings.Builder safe for the worker's log and the host's
|
||||
// call log to write at once.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
b strings.Builder
|
||||
}
|
||||
|
||||
func (s *syncBuffer) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.Write(p)
|
||||
}
|
||||
|
||||
func (s *syncBuffer) String() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.String()
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
|
||||
)
|
||||
|
||||
type message struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// testHost runs a worker and answers its host calls the way Cletus does,
|
||||
// within the plugin's declared permissions. Every host call is logged.
|
||||
type testHost struct {
|
||||
plugin *manifest.Plugin
|
||||
filesDir string // the folder the user "opened"
|
||||
workspaceDir string // backs the one simulated shared folder ("" = none)
|
||||
dataDir string
|
||||
edit bool
|
||||
capabilities map[string]bool
|
||||
log io.Writer
|
||||
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
writeMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
pending map[string]chan message
|
||||
next int
|
||||
exited chan struct{}
|
||||
}
|
||||
|
||||
const testWorkspaceID = "ws:00000000-0000-0000-0000-000000000001"
|
||||
|
||||
func (h *testHost) start(bin string) error {
|
||||
if err := os.MkdirAll(h.dataDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
h.cmd = exec.Command(bin)
|
||||
h.cmd.Dir = h.dataDir
|
||||
h.cmd.Env = []string{"CLETUS_PLUGIN_ID=" + h.plugin.ID, "CLETUS_PLUGIN_DATA=" + h.dataDir}
|
||||
for _, name := range []string{"HOME", "USERPROFILE", "PATH", "TMPDIR", "TEMP", "TMP", "LANG", "SystemRoot"} {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
h.cmd.Env = append(h.cmd.Env, name+"="+v)
|
||||
}
|
||||
}
|
||||
h.cmd.Stderr = prefixWriter{w: h.log, prefix: "worker "}
|
||||
var err error
|
||||
if h.stdin, err = h.cmd.StdinPipe(); err != nil {
|
||||
return err
|
||||
}
|
||||
stdout, err := h.cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.pending = map[string]chan message{}
|
||||
h.exited = make(chan struct{})
|
||||
if err := h.cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go h.read(stdout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var hs struct {
|
||||
APIVersion int `json:"api_version"`
|
||||
}
|
||||
if err := h.call(ctx, "plugin.handshake", nil, &hs); err != nil {
|
||||
return fmt.Errorf("handshake: %w", err)
|
||||
}
|
||||
if hs.APIVersion != 1 {
|
||||
return fmt.Errorf("handshake: the worker speaks api_version %d; Cletus needs 1", hs.APIVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *testHost) stop() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_ = h.call(ctx, "plugin.shutdown", nil, nil)
|
||||
cancel()
|
||||
h.stdin.Close()
|
||||
select {
|
||||
case <-h.exited:
|
||||
case <-time.After(2 * time.Second):
|
||||
_ = h.cmd.Process.Kill()
|
||||
<-h.exited
|
||||
}
|
||||
}
|
||||
|
||||
func (h *testHost) read(stdout io.Reader) {
|
||||
defer close(h.exited)
|
||||
defer func() { _ = h.cmd.Wait() }()
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 64<<10), 64<<20+1)
|
||||
for sc.Scan() {
|
||||
var m message
|
||||
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
|
||||
fmt.Fprintf(h.log, "host the worker wrote something that isn't a message (is it printing to stdout?): %.200s\n", sc.Text())
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case m.Method == "$/cancel":
|
||||
case m.Method != "":
|
||||
go h.serve(m)
|
||||
default:
|
||||
h.mu.Lock()
|
||||
ch := h.pending[m.ID]
|
||||
delete(h.pending, m.ID)
|
||||
h.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
for id, ch := range h.pending {
|
||||
close(ch)
|
||||
delete(h.pending, id)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *testHost) send(m message) error {
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.writeMu.Lock()
|
||||
defer h.writeMu.Unlock()
|
||||
_, err = h.stdin.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *testHost) call(ctx context.Context, method string, params, out any) error {
|
||||
var raw json.RawMessage
|
||||
if params != nil {
|
||||
var err error
|
||||
if raw, err = json.Marshal(params); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ch := make(chan message, 1)
|
||||
h.mu.Lock()
|
||||
h.next++
|
||||
id := fmt.Sprintf("h%d", h.next)
|
||||
h.pending[id] = ch
|
||||
h.mu.Unlock()
|
||||
if err := h.send(message{ID: id, Method: method, Params: raw}); err != nil {
|
||||
return fmt.Errorf("the worker has stopped: %w", err)
|
||||
}
|
||||
select {
|
||||
case reply, ok := <-ch:
|
||||
if !ok {
|
||||
return errors.New("the worker stopped before answering")
|
||||
}
|
||||
if reply.Error != nil {
|
||||
return errors.New(reply.Error.Message)
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(reply.Result, out)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
cancelID, _ := json.Marshal(id)
|
||||
_ = h.send(message{Method: "$/cancel", Params: cancelID})
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// serve answers one host call from the worker.
|
||||
func (h *testHost) serve(m message) {
|
||||
result, err := h.answer(m.Method, m.Params)
|
||||
reply := message{ID: m.ID}
|
||||
outcome := "ok"
|
||||
if err != nil {
|
||||
reply.Error = &struct {
|
||||
Message string `json:"message"`
|
||||
}{err.Error()}
|
||||
outcome = "error: " + err.Error()
|
||||
} else {
|
||||
reply.Result, _ = json.Marshal(result)
|
||||
}
|
||||
fmt.Fprintf(h.log, "host %s %s → %s\n", m.Method, clip(string(m.Params), 200), outcome)
|
||||
if m.ID != "" {
|
||||
_ = h.send(reply)
|
||||
}
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *testHost) answer(method string, raw json.RawMessage) (any, error) {
|
||||
if ok, need := h.plugin.Allowed(method); !ok {
|
||||
if need != "" {
|
||||
return nil, fmt.Errorf("this plugin is not allowed to use %s; it needs the %s permission", method, need)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown plugin host operation %q", method)
|
||||
}
|
||||
var p struct {
|
||||
Path, Key, Workspace, Prefix, Name, Data, ContentType string
|
||||
TTL int
|
||||
Args []any
|
||||
}
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
switch method {
|
||||
case "host.auth.has_capability":
|
||||
var c struct{ Capability string }
|
||||
_ = json.Unmarshal(raw, &c)
|
||||
return map[string]bool{"granted": h.capabilities[c.Capability]}, nil
|
||||
case "host.auth.whoami":
|
||||
return map[string]any{"key_id": 1, "name": "Test User", "org": "Test Organization", "org_id": "00000000-0000-0000-0000-000000000000"}, nil
|
||||
case "host.files.resolve":
|
||||
return h.resolve(p.Path)
|
||||
case "host.files.can_write":
|
||||
path, err := h.resolve(p.Path)
|
||||
if err != nil {
|
||||
path, err = h.within(p.Path)
|
||||
}
|
||||
return err == nil && h.edit && path != "", nil
|
||||
case "host.event":
|
||||
if !strings.HasPrefix(p.Name, h.plugin.ID+":") {
|
||||
return nil, errors.New("plugin event is not permitted")
|
||||
}
|
||||
return nil, nil
|
||||
case "host.screen.open":
|
||||
return nil, errors.New("screens aren't available yet")
|
||||
case "host.dialog.open", "host.dialog.folder", "host.dialog.save", "host.dialog.message":
|
||||
return nil, errors.New("the test host has no dialogs; pass paths as tool arguments while testing")
|
||||
case "host.plugin.call":
|
||||
return nil, errors.New("the test host runs no other plugins")
|
||||
case "host.data.publish":
|
||||
path, err := h.inData(p.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.TTL < 1 || p.TTL > 3600 {
|
||||
return nil, errors.New("artifact ttl must be 1 to 3600 seconds")
|
||||
}
|
||||
return map[string]string{"token": "test-token", "url": "file://" + filepath.ToSlash(path), "expiresAt": time.Now().Add(time.Duration(p.TTL) * time.Second).UTC().Format(time.RFC3339)}, nil
|
||||
case "host.data.revoke":
|
||||
return nil, nil
|
||||
case "host.data.leases":
|
||||
return 0, nil
|
||||
case "host.data.remove":
|
||||
path, err := h.inData(p.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
case "host.agent.start", "host.agent.status", "host.agent.cancel":
|
||||
return nil, errors.New("background runs aren't available in the test host")
|
||||
}
|
||||
if strings.HasPrefix(method, "host.workspace.") {
|
||||
return h.workspace(method, p.Workspace, p.Key, p.Prefix, p.Path, p.Data)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown plugin host operation %q", method)
|
||||
}
|
||||
|
||||
// resolve answers host.files.resolve: a path inside the opened folder.
|
||||
func (h *testHost) resolve(path string) (string, error) {
|
||||
resolved, err := h.within(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(resolved); err != nil {
|
||||
return "", fmt.Errorf("%s does not exist", path)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (h *testHost) within(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", errors.New("path is empty")
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(h.filesDir, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if !inside(h.filesDir, path) {
|
||||
return "", fmt.Errorf("%s is outside the files the user opened", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (h *testHost) inData(path string) (string, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(h.dataDir, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if !inside(h.dataDir, path) {
|
||||
return "", errors.New("the path must be inside the plugin's data folder")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func inside(root, path string) bool {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
|
||||
)
|
||||
|
||||
func newTestHost(t *testing.T, perms ...string) *testHost {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
files, ws := filepath.Join(root, "files"), filepath.Join(root, "ws")
|
||||
for _, d := range []string{files, ws} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(files, "a.txt"), []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ws, "ref.csv"), []byte("x,y"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &testHost{plugin: &manifest.Plugin{ID: "acme.demo", Permissions: perms}, filesDir: files, workspaceDir: ws,
|
||||
dataDir: filepath.Join(root, "data"), capabilities: map[string]bool{}}
|
||||
}
|
||||
|
||||
func ask(t *testing.T, h *testHost, method string, params any) (any, error) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(params)
|
||||
return h.answer(method, raw)
|
||||
}
|
||||
|
||||
func TestPermissionsGateHostCalls(t *testing.T) {
|
||||
h := newTestHost(t)
|
||||
_, err := ask(t, h, "host.files.resolve", map[string]string{"path": "a.txt"})
|
||||
if err == nil || !strings.Contains(err.Error(), "needs the files permission") {
|
||||
t.Fatalf("undeclared files call = %v", err)
|
||||
}
|
||||
if _, err := ask(t, h, "host.code.llm.start", nil); err == nil || !strings.Contains(err.Error(), "unknown plugin host operation") {
|
||||
t.Fatalf("first-party call = %v", err)
|
||||
}
|
||||
if _, err := ask(t, h, "host.event", map[string]any{"name": "other:x"}); err == nil {
|
||||
t.Fatal("an event outside the plugin's prefix was allowed")
|
||||
}
|
||||
if _, err := ask(t, h, "host.event", map[string]any{"name": "acme.demo:x"}); err != nil {
|
||||
t.Fatalf("own event = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesStayInsideTheOpenedFolder(t *testing.T) {
|
||||
h := newTestHost(t, "files")
|
||||
got, err := ask(t, h, "host.files.resolve", map[string]string{"path": "a.txt"})
|
||||
if err != nil || got != filepath.Join(h.filesDir, "a.txt") {
|
||||
t.Fatalf("resolve = %v, %v", got, err)
|
||||
}
|
||||
for _, path := range []string{"../escape.txt", "/etc/hosts"} {
|
||||
if _, err := ask(t, h, "host.files.resolve", map[string]string{"path": path}); err == nil {
|
||||
t.Errorf("resolve(%s) was allowed", path)
|
||||
}
|
||||
}
|
||||
if got, _ := ask(t, h, "host.files.can_write", map[string]string{"path": "new.txt"}); got != false {
|
||||
t.Fatal("can_write without -edit should be false")
|
||||
}
|
||||
h.edit = true
|
||||
if got, _ := ask(t, h, "host.files.can_write", map[string]string{"path": "new.txt"}); got != true {
|
||||
t.Fatal("can_write with -edit should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceSimulation(t *testing.T) {
|
||||
h := newTestHost(t, "files", "workspace.read", "workspace.write")
|
||||
listed, err := ask(t, h, "host.workspace.list", map[string]string{"workspace": testWorkspaceName})
|
||||
if err != nil || len(listed.([]map[string]any)) != 1 {
|
||||
t.Fatalf("list = %v, %v", listed, err)
|
||||
}
|
||||
key := testWorkspaceID + "/files/ref.csv"
|
||||
fetched, err := ask(t, h, "host.workspace.fetch", map[string]string{"key": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := fetched.(map[string]string)["path"]
|
||||
if _, err := ask(t, h, "host.files.resolve", map[string]string{"path": path}); err != nil {
|
||||
t.Fatalf("a fetched copy should resolve: %v", err)
|
||||
}
|
||||
data := base64.StdEncoding.EncodeToString([]byte("new"))
|
||||
if _, err := ask(t, h, "host.workspace.put", map[string]string{"workspace": testWorkspaceID, "key": "out/r.txt", "data": data}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ask(t, h, "host.workspace.put", map[string]string{"workspace": testWorkspaceID, "key": "../x", "data": data}); err == nil {
|
||||
t.Fatal("a key with .. was accepted")
|
||||
}
|
||||
if _, err := ask(t, h, "host.workspace.read", map[string]string{"key": testWorkspaceID + "/files/out/r.txt"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Command cletus-plugin creates, checks and runs Cletus plugins.
|
||||
//
|
||||
// cletus-plugin new <publisher>.<name> [folder]
|
||||
// cletus-plugin check
|
||||
// cletus-plugin run [flags] tools | invoke <tool> [json] | ui <method> [json array] | service <service.method> [json]
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// sdkVersion is the SDK release new plugins require (tag sdk/<version>).
|
||||
const sdkVersion = "v0.1.0"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "new":
|
||||
err = runNew(os.Args[2:])
|
||||
case "check":
|
||||
err = runCheck(os.Args[2:])
|
||||
case "run":
|
||||
err = runRun(os.Args[2:])
|
||||
case "version":
|
||||
fmt.Println("cletus-plugin", sdkVersion)
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cletus-plugin:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `cletus-plugin builds Cletus plugins.
|
||||
|
||||
cletus-plugin new <publisher>.<name> [folder] create a plugin from the template
|
||||
cletus-plugin check run the store's automated checks here
|
||||
cletus-plugin run [flags] <command> run the worker under the test host
|
||||
tools list the chat tools
|
||||
invoke <tool> [json] call a chat tool
|
||||
ui <method> [json array] call a screen method
|
||||
service <service.method> [json] call a provided service
|
||||
flags: -files <dir> the folder the user "opened" (default: testdata)
|
||||
-workspace <dir> simulate one shared folder backed by this directory
|
||||
-edit the user allows edits (default: read-only)
|
||||
-capability <name> a Cletus feature the user has (repeatable)
|
||||
cletus-plugin version
|
||||
|
||||
Docs: https://git.eidonix.com/eidonix/cletus-plugins
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"unicode"
|
||||
|
||||
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
|
||||
)
|
||||
|
||||
//go:embed all:template
|
||||
var templateFS embed.FS
|
||||
|
||||
const sdkModule = "git.eidonix.com/eidonix/cletus-plugins/sdk"
|
||||
|
||||
type templateData struct {
|
||||
ID, Publisher, Name, Title, Short, Module, SDKModule, SDKVersion string
|
||||
}
|
||||
|
||||
func runNew(args []string) error {
|
||||
fs := flag.NewFlagSet("new", flag.ContinueOnError)
|
||||
sdkDir := fs.String("sdk-dir", "", "use a local copy of the SDK (for developing the SDK itself)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() < 1 || fs.NArg() > 2 {
|
||||
return errors.New("usage: cletus-plugin new <publisher>.<name> [folder]")
|
||||
}
|
||||
id := fs.Arg(0)
|
||||
probe := manifest.Plugin{ID: id, Name: "x", Description: "x", Version: "0.1.0", MinAppVersion: "0.5.0", HostAPI: manifest.HostAPI}
|
||||
if errs := probe.Validate(); len(errs) > 0 {
|
||||
return errs[0]
|
||||
}
|
||||
publisher, name, _ := strings.Cut(id, ".")
|
||||
dir := name
|
||||
if fs.NArg() == 2 {
|
||||
dir = fs.Arg(1)
|
||||
}
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return fmt.Errorf("%s already exists", dir)
|
||||
}
|
||||
data := templateData{ID: id, Publisher: publisher, Name: name, Title: title(name),
|
||||
Short: strings.ReplaceAll(name, "-", "_"), Module: publisher + ".plugins/" + name,
|
||||
SDKModule: sdkModule, SDKVersion: sdkVersion}
|
||||
if err := writeTemplate(dir, data); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Created %s in %s.\n", id, dir)
|
||||
|
||||
if *sdkDir != "" {
|
||||
abs, err := filepath.Abs(*sdkDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only for developing the SDK: check refuses replace directives.
|
||||
if out, err := goRun(dir, goEnv("", ""), "mod", "edit", "-replace", sdkModule+"="+abs); err != nil {
|
||||
return fmt.Errorf("go mod edit: %s", out)
|
||||
}
|
||||
}
|
||||
for _, step := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
|
||||
if out, err := goRun(dir, goEnv("", ""), step...); err != nil {
|
||||
return fmt.Errorf("go %s failed. Fix the problem below and run it in %s yourself:\n%s", strings.Join(step, " "), dir, out)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nNext:\n cd %s\n cletus-plugin check\n cletus-plugin run tools\n cletus-plugin run invoke %s_count_lines '{\"path\":\"sample.txt\"}'\n", dir, data.Short)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeTemplate(dir string, data templateData) error {
|
||||
return fs.WalkDir(templateFS, "template", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel := strings.TrimPrefix(path, "template/")
|
||||
rel = strings.TrimSuffix(rel, ".tmpl")
|
||||
rel = strings.ReplaceAll(rel, "_dot_", ".")
|
||||
src, err := templateFS.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := template.New(rel).Delims("[[", "]]").Parse(string(src))
|
||||
if err != nil {
|
||||
return fmt.Errorf("template %s: %w", rel, err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := t.Execute(&out, data); err != nil {
|
||||
return fmt.Errorf("template %s: %w", rel, err)
|
||||
}
|
||||
dst := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, out.Bytes(), 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
func title(name string) string {
|
||||
words := strings.Split(name, "-")
|
||||
for i, w := range words {
|
||||
if w != "" {
|
||||
r := []rune(w)
|
||||
r[0] = unicode.ToUpper(r[0])
|
||||
words[i] = string(r)
|
||||
}
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
|
||||
)
|
||||
|
||||
type listFlag []string
|
||||
|
||||
func (l *listFlag) String() string { return strings.Join(*l, ",") }
|
||||
func (l *listFlag) Set(v string) error { *l = append(*l, v); return nil }
|
||||
|
||||
func runRun(args []string) error {
|
||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||
files := fs.String("files", "testdata", "the folder the user opened")
|
||||
workspace := fs.String("workspace", "", "a directory to serve as one shared folder")
|
||||
edit := fs.Bool("edit", false, "the user allows edits")
|
||||
timeout := fs.Duration("timeout", 2*time.Minute, "how long to wait for the answer")
|
||||
var caps listFlag
|
||||
fs.Var(&caps, "capability", "a Cletus feature the user has (repeatable)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
rest := fs.Args()
|
||||
if len(rest) == 0 {
|
||||
return errors.New("run needs a command: tools, invoke, ui or service")
|
||||
}
|
||||
plugin, err := readPlugin(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wd, _ := os.Getwd()
|
||||
filesDir, err := filepath.Abs(*files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filesDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
wsDir := ""
|
||||
if *workspace != "" {
|
||||
if wsDir, err = filepath.Abs(*workspace); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
bin, cleanup, err := buildWorker(wd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
host := &testHost{plugin: plugin, filesDir: filesDir, workspaceDir: wsDir, edit: *edit,
|
||||
dataDir: filepath.Join(wd, ".cletus-dev", "data"), capabilities: map[string]bool{}, log: os.Stderr}
|
||||
for _, c := range caps {
|
||||
host.capabilities[c] = true
|
||||
}
|
||||
if err := host.start(bin); err != nil {
|
||||
return err
|
||||
}
|
||||
defer host.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
arg := func(i int, fallback string) string {
|
||||
if len(rest) > i {
|
||||
return rest[i]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
var out json.RawMessage
|
||||
switch rest[0] {
|
||||
case "tools":
|
||||
err = host.call(ctx, "tools.list", nil, &out)
|
||||
case "invoke":
|
||||
if len(rest) < 2 {
|
||||
return errors.New("invoke needs a tool name")
|
||||
}
|
||||
err = host.call(ctx, "tools.invoke", map[string]any{"name": rest[1], "args": arg(2, "{}"), "working_dir": filesDir, "edit_mode": *edit}, &out)
|
||||
case "ui":
|
||||
if len(rest) < 2 {
|
||||
return errors.New("ui needs a method name")
|
||||
}
|
||||
err = host.call(ctx, "ui."+strings.TrimPrefix(rest[1], "ui."), json.RawMessage(arg(2, "[]")), &out)
|
||||
case "service":
|
||||
if len(rest) < 2 {
|
||||
return errors.New("service needs <service>.<method>")
|
||||
}
|
||||
err = host.call(ctx, "svc."+rest[1], map[string]any{"caller": "cletus-plugin", "mutating": false, "args": json.RawMessage(arg(2, "{}"))}, &out)
|
||||
default:
|
||||
return fmt.Errorf("unknown run command %q", rest[0])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pretty bytes.Buffer
|
||||
if json.Indent(&pretty, out, "", " ") != nil {
|
||||
pretty.Write(out)
|
||||
}
|
||||
fmt.Println(pretty.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPlugin(dir string) (*manifest.Plugin, error) {
|
||||
path := filepath.Join(dir, "plugin.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, manifest.ErrNoManifest
|
||||
}
|
||||
return manifest.Read(path)
|
||||
}
|
||||
|
||||
// prefixWriter prefixes every line written to w.
|
||||
type prefixWriter struct {
|
||||
w io.Writer
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (p prefixWriter) Write(b []byte) (int, error) {
|
||||
for _, line := range strings.SplitAfter(string(b), "\n") {
|
||||
if line != "" {
|
||||
fmt.Fprint(p.w, p.prefix+line)
|
||||
}
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Rules for coding agents
|
||||
|
||||
This is a Cletus plugin. Before changing anything, read the plugin kit and
|
||||
follow its rules exactly:
|
||||
|
||||
- Rules: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/AGENTS.md
|
||||
- Specification: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/specification.md
|
||||
- Host API: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/host-api.md
|
||||
- Style guide: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/style-guide.md
|
||||
- Review checklist: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/review-checklist.md
|
||||
|
||||
If you can't read them, stop and ask the user for a copy of the kit.
|
||||
|
||||
Commands:
|
||||
|
||||
cletus-plugin check # must pass before you say you're done
|
||||
cletus-plugin run tools # list the chat tools
|
||||
cletus-plugin run invoke <tool> '<json>'
|
||||
go test ./...
|
||||
|
||||
Keep docs/plan.md, plugin.json and CHANGELOG.md in step with the code.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- First version: count the lines in a text file, and save a summary next to it.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,2 @@
|
||||
/.cletus-dev/
|
||||
/testdata/.cletus/
|
||||
@@ -0,0 +1,18 @@
|
||||
// Command worker is the [[.Title]] plugin's worker.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"[[.SDKModule]]/pluginsdk"
|
||||
|
||||
"[[.Module]]/internal/tools"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := pluginsdk.Serve(pluginsdk.Handlers{Tools: tools.All()}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# [[.Title]]
|
||||
|
||||
Ask Cletus to count the lines in a file, or to save a summary of it.
|
||||
|
||||
- "How many lines are in report.txt?"
|
||||
- "Save a line-count summary next to report.txt."
|
||||
|
||||
Saving needs Cletus to allow edits in the folder.
|
||||
@@ -0,0 +1,25 @@
|
||||
# [[.Title]]: plan
|
||||
|
||||
## What it does
|
||||
|
||||
Counts the lines in a text file the user chooses, and saves a short summary
|
||||
next to it.
|
||||
|
||||
## Chat tools
|
||||
|
||||
- `[[.Short]]_count_lines`: reads a file and returns its line count. Read-only.
|
||||
- `[[.Short]]_save_summary`: writes `<file>.summary.txt` next to the file.
|
||||
Mutating; checks `host.files.can_write` first.
|
||||
|
||||
## Permissions
|
||||
|
||||
- `files`: to read the file the user chose and write the summary beside it.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `[[.SDKModule]]`: the Cletus plugin SDK.
|
||||
|
||||
## Notes for review
|
||||
|
||||
- Direct file access: `internal/tools` reads and writes only paths returned
|
||||
by `host.files.resolve`, after `host.files.can_write` for writes.
|
||||
@@ -0,0 +1,5 @@
|
||||
module [[.Module]]
|
||||
|
||||
go 1.24
|
||||
|
||||
require [[.SDKModule]] [[.SDKVersion]]
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package tools holds the plugin's chat tools.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"[[.SDKModule]]/pluginsdk"
|
||||
)
|
||||
|
||||
// All returns every chat tool. Each is also listed in plugin.json.
|
||||
func All() []pluginsdk.Tool {
|
||||
return []pluginsdk.Tool{countLines, saveSummary}
|
||||
}
|
||||
|
||||
var pathParam = map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "The text file, as the user named it."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
}
|
||||
|
||||
var countLines = pluginsdk.Tool{
|
||||
Name: "[[.Short]]_count_lines",
|
||||
Description: "Count the lines in a text file. Use when the user asks how long a text file is. Returns the line count.",
|
||||
Parameters: pathParam,
|
||||
Run: func(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) pluginsdk.ToolResult {
|
||||
path, lines, err := count(ctx, h, call)
|
||||
if err != nil {
|
||||
return pluginsdk.Fail("%v", err)
|
||||
}
|
||||
return pluginsdk.Text("%s has %d lines.", path, lines)
|
||||
},
|
||||
}
|
||||
|
||||
var saveSummary = pluginsdk.Tool{
|
||||
Name: "[[.Short]]_save_summary",
|
||||
Description: "Save a one-line summary (the line count) next to a text file, as <file>.summary.txt. Use only when the user asks to save it.",
|
||||
Parameters: pathParam,
|
||||
Mutating: true,
|
||||
Run: func(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) pluginsdk.ToolResult {
|
||||
path, lines, err := count(ctx, h, call)
|
||||
if err != nil {
|
||||
return pluginsdk.Fail("%v", err)
|
||||
}
|
||||
out := path + ".summary.txt"
|
||||
allowed, err := h.CanWrite(ctx, out)
|
||||
if err != nil {
|
||||
return pluginsdk.Fail("%v", err)
|
||||
}
|
||||
if !allowed || !call.EditMode {
|
||||
return pluginsdk.Fail("Cletus doesn't allow edits in this folder. Ask the user to allow edits, then try again.")
|
||||
}
|
||||
if err := os.WriteFile(out, []byte(fmt.Sprintf("%d lines\n", lines)), 0o644); err != nil {
|
||||
return pluginsdk.Fail("Couldn't save the summary: %v", err)
|
||||
}
|
||||
return pluginsdk.Text("Saved %s.", out)
|
||||
},
|
||||
}
|
||||
|
||||
// count resolves the path through the host and counts its lines.
|
||||
func count(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) (string, int, error) {
|
||||
var args struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := call.Decode(&args); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if args.Path == "" {
|
||||
return "", 0, fmt.Errorf("give the path of a text file")
|
||||
}
|
||||
path, err := h.ResolvePath(ctx, args.Path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("couldn't read %s: %v", args.Path, err)
|
||||
}
|
||||
lines := bytes.Count(data, []byte("\n"))
|
||||
if len(data) > 0 && data[len(data)-1] != '\n' {
|
||||
lines++
|
||||
}
|
||||
return path, lines, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"[[.SDKModule]]/pluginsdk"
|
||||
)
|
||||
|
||||
// fakeHost answers host.files.* for files inside dir, like Cletus does for
|
||||
// the folder the user opened.
|
||||
func fakeHost(dir string, canWrite bool) *pluginsdk.Host {
|
||||
return pluginsdk.NewTestHost(func(method string, params json.RawMessage) (any, error) {
|
||||
var p struct{ Path string }
|
||||
_ = json.Unmarshal(params, &p)
|
||||
path := p.Path
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(dir, path)
|
||||
}
|
||||
switch method {
|
||||
case "host.files.resolve":
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, errors.New(p.Path + " does not exist")
|
||||
}
|
||||
return path, nil
|
||||
case "host.files.can_write":
|
||||
return canWrite, nil
|
||||
}
|
||||
return nil, errors.New("unexpected host call " + method)
|
||||
})
|
||||
}
|
||||
|
||||
func run(t *testing.T, tool pluginsdk.Tool, h *pluginsdk.Host, args string, edit bool) pluginsdk.ToolResult {
|
||||
t.Helper()
|
||||
return tool.Run(context.Background(), h, pluginsdk.ToolCall{Args: json.RawMessage(args), EditMode: edit})
|
||||
}
|
||||
|
||||
func TestCountLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\ntwo\nthree"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := run(t, countLines, fakeHost(dir, false), `{"path":"a.txt"}`, false)
|
||||
if got.Error != "" || !strings.Contains(got.Text, "3 lines") {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if got := run(t, countLines, fakeHost(dir, false), `{"path":"missing.txt"}`, false); got.Error == "" {
|
||||
t.Fatal("a missing file should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSummaryNeedsEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(t, saveSummary, fakeHost(dir, false), `{"path":"a.txt"}`, false); got.Error == "" {
|
||||
t.Fatal("saving without edits allowed should fail")
|
||||
}
|
||||
got := run(t, saveSummary, fakeHost(dir, true), `{"path":"a.txt"}`, true)
|
||||
if got.Error != "" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "a.txt.summary.txt"))
|
||||
if err != nil || string(data) != "1 lines\n" {
|
||||
t.Fatalf("summary = %q, %v", data, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"summary": "Counts the lines in a text file.",
|
||||
"category": "Productivity",
|
||||
"visibility": "store",
|
||||
"organizations": [],
|
||||
"support": "",
|
||||
"price": "free"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "[[.ID]]",
|
||||
"name": "[[.Title]]",
|
||||
"description": "Counts the lines in a text file and saves a summary next to it. Replace this with what your plugin does.",
|
||||
"version": "0.1.0",
|
||||
"min_app_version": "0.5.0",
|
||||
"host_api": 2,
|
||||
"color": "teal",
|
||||
"permissions": ["files"],
|
||||
"tools": [
|
||||
{"name": "[[.Short]]_count_lines", "mutating": false},
|
||||
{"name": "[[.Short]]_save_summary", "mutating": true}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
first line
|
||||
second line
|
||||
third line
|
||||
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const testWorkspaceName = "Test folder"
|
||||
|
||||
// workspace answers host.workspace.* from one directory, as one shared
|
||||
// folder the user owns.
|
||||
func (h *testHost) workspace(method, workspace, key, prefix, src, data string) (any, error) {
|
||||
if h.workspaceDir == "" {
|
||||
return nil, errors.New("no shared folders in the test host; pass -workspace <dir> to simulate one")
|
||||
}
|
||||
keyPath := func(key string) (string, error) {
|
||||
rel, ok := strings.CutPrefix(key, testWorkspaceID+"/files/")
|
||||
if !ok || rel == "" {
|
||||
return "", fmt.Errorf("%q is not a file in a shared folder (keys look like %s/files/<path>)", key, testWorkspaceID)
|
||||
}
|
||||
return h.workspaceFile(rel)
|
||||
}
|
||||
folder := func() error {
|
||||
if workspace != testWorkspaceID && workspace != testWorkspaceName {
|
||||
return fmt.Errorf("you are not a member of a shared folder %q", workspace)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch method {
|
||||
case "host.workspace.folders":
|
||||
return []map[string]string{{"id": testWorkspaceID, "name": testWorkspaceName, "role": "owner"}}, nil
|
||||
case "host.workspace.list":
|
||||
if err := folder(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []map[string]any
|
||||
err := filepath.WalkDir(h.workspaceDir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || len(out) >= 1000 {
|
||||
return err
|
||||
}
|
||||
rel := filepath.ToSlash(mustRel(h.workspaceDir, p))
|
||||
if !strings.HasPrefix(rel, prefix) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
etag, err := fileETag(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, map[string]any{"key": testWorkspaceID + "/files/" + rel, "size": info.Size(), "etag": etag, "modified": info.ModTime().UTC()})
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
case "host.workspace.fetch":
|
||||
file, err := keyPath(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
etag, err := fileETag(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s does not exist", key)
|
||||
}
|
||||
// Like Cletus: a cached copy in the working folder's scratch area,
|
||||
// which host.files.resolve accepts.
|
||||
dst := filepath.Join(h.filesDir, ".cletus", "tmp", "workspace", filepath.FromSlash(strings.TrimPrefix(key, testWorkspaceID+"/files/")))
|
||||
if err := copyFile(file, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]string{"path": dst, "etag": etag}, nil
|
||||
case "host.workspace.read":
|
||||
file, err := keyPath(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s does not exist", key)
|
||||
}
|
||||
if info.Size() > 2<<20 {
|
||||
return nil, fmt.Errorf("%s is larger than 2 MiB; use host.workspace.fetch", key)
|
||||
}
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
etag, _ := fileETag(file)
|
||||
return map[string]any{"data": base64.StdEncoding.EncodeToString(body), "etag": etag, "size": len(body)}, nil
|
||||
case "host.workspace.put":
|
||||
if err := folder(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key == "" || strings.Contains(key, "..") || strings.HasPrefix(key, "/") || strings.Contains(key, "//") {
|
||||
return nil, fmt.Errorf("key %q must be a relative path with no empty or .. segments", key)
|
||||
}
|
||||
dst, err := h.workspaceFile(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body []byte
|
||||
if src != "" {
|
||||
resolved, err := h.resolve(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body, err = os.ReadFile(resolved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if body, err = base64.StdEncoding.DecodeString(data); err != nil {
|
||||
return nil, errors.New("data must be base64")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(dst, body, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
etag, _ := fileETag(dst)
|
||||
return map[string]any{"key": testWorkspaceID + "/files/" + key, "size": len(body), "etag": etag}, nil
|
||||
case "host.workspace.delete":
|
||||
file, err := keyPath(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Remove(file); err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]string{"key": key}, nil
|
||||
case "host.workspace.create":
|
||||
return nil, errors.New("the test host has one shared folder; it can't create another")
|
||||
}
|
||||
return nil, fmt.Errorf("unknown plugin host operation %q", method)
|
||||
}
|
||||
|
||||
func (h *testHost) workspaceFile(rel string) (string, error) {
|
||||
file := filepath.Join(h.workspaceDir, filepath.FromSlash(path.Clean("/"+rel)))
|
||||
if !inside(h.workspaceDir, file) {
|
||||
return "", errors.New("the key is outside the shared folder")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func mustRel(root, p string) string {
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
return rel
|
||||
}
|
||||
|
||||
func fileETag(p string) (string, error) {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
sum := sha256.New()
|
||||
if _, err := io.Copy(sum, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(sum.Sum(nil))[:32], nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.eidonix.com/eidonix/cletus-plugins/sdk
|
||||
|
||||
go 1.24
|
||||
@@ -0,0 +1,296 @@
|
||||
// Package manifest reads and checks plugin.json, and holds the permission
|
||||
// table: which host methods each permission unlocks (docs/specification.md,
|
||||
// "Permissions"; docs/host-api.md).
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HostAPI is the host API version this SDK targets.
|
||||
const HostAPI = 2
|
||||
|
||||
// Plugin is plugin.json.
|
||||
type Plugin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
MinAppVersion string `json:"min_app_version"`
|
||||
HostAPI int `json:"host_api"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
FileTypes []string `json:"file_types,omitempty"`
|
||||
OpensFolders bool `json:"opens_folders,omitempty"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Provides map[string]Service `json:"provides,omitempty"`
|
||||
Uses []string `json:"uses,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
|
||||
// Fields a publisher may not set, or that wait for a later release.
|
||||
UI json.RawMessage `json:"ui,omitempty"`
|
||||
UIHostAPI json.RawMessage `json:"ui_host_api,omitempty"`
|
||||
Free *bool `json:"free,omitempty"`
|
||||
Capability string `json:"capability,omitempty"`
|
||||
HostSeams []string `json:"host_seams,omitempty"`
|
||||
}
|
||||
|
||||
// Tool is one entry of plugin.json "tools".
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Mutating bool `json:"mutating"`
|
||||
RequiresApproval bool `json:"requires_approval,omitempty"`
|
||||
}
|
||||
|
||||
// Service is one entry of plugin.json "provides".
|
||||
type Service struct {
|
||||
Methods map[string]Method `json:"methods"`
|
||||
}
|
||||
|
||||
// Method is one method of a provided service.
|
||||
type Method struct {
|
||||
Description string `json:"description"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Mutating bool `json:"mutating,omitempty"`
|
||||
Long bool `json:"long,omitempty"`
|
||||
}
|
||||
|
||||
// Read reads and decodes plugin.json, refusing unknown fields.
|
||||
func Read(path string) (*Plugin, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
var p Plugin
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Always are the host methods every plugin may call.
|
||||
var Always = []string{
|
||||
"host.auth.has_capability",
|
||||
"host.data.publish", "host.data.revoke", "host.data.leases", "host.data.remove",
|
||||
"host.event", "host.screen.open",
|
||||
"host.dialog.open", "host.dialog.folder", "host.dialog.save", "host.dialog.message",
|
||||
"host.plugin.call",
|
||||
}
|
||||
|
||||
// Permission is one permission a plugin may declare.
|
||||
type Permission struct {
|
||||
Words string // shown to the admin and the user
|
||||
Methods []string // host methods it unlocks (empty: enforced by review)
|
||||
}
|
||||
|
||||
// Permissions are the fixed permission tokens. net:<domain> is separate.
|
||||
var Permissions = map[string]Permission{
|
||||
"files": {"Open and save files you choose", []string{"host.files.resolve", "host.files.can_write"}},
|
||||
"files.computer": {"Read and write other files on this computer", nil},
|
||||
"workspace.read": {"Read files in shared folders you're a member of", []string{"host.workspace.folders", "host.workspace.list", "host.workspace.fetch", "host.workspace.read"}},
|
||||
"workspace.write": {"Add, change and delete files in shared folders", []string{"host.workspace.put", "host.workspace.delete", "host.workspace.create"}},
|
||||
"agents": {"Run Cletus in the background", []string{"host.agent.start", "host.agent.status", "host.agent.cancel"}},
|
||||
"chat.handoff": {"Send work to chat", nil},
|
||||
"identity": {"See your name and organization", []string{"host.auth.whoami"}},
|
||||
"net.configured": {"Connect to devices and servers you set up in this plugin", nil},
|
||||
"processes": {"Run programs on this computer", nil},
|
||||
}
|
||||
|
||||
// NetPrefix starts a net:<domain> permission.
|
||||
const NetPrefix = "net:"
|
||||
|
||||
// MaxNetDomains bounds the net:<domain> permissions.
|
||||
const MaxNetDomains = 16
|
||||
|
||||
var (
|
||||
idRE = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}\.[a-z][a-z0-9-]{0,47}$`)
|
||||
semverRE = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`)
|
||||
toolRE = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
serviceRE = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$`)
|
||||
methodRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||
useRE = regexp.MustCompile(`^[a-z0-9.-]+:[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*\.([a-z][a-z0-9_]*|\*)$`)
|
||||
domainRE = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
|
||||
extRE = regexp.MustCompile(`^\.[a-z0-9]{1,16}$`)
|
||||
)
|
||||
|
||||
var colors = map[string]bool{"": true, "red": true, "orange": true, "amber": true, "yellow": true, "green": true,
|
||||
"teal": true, "blue": true, "indigo": true, "violet": true, "pink": true, "grey": true}
|
||||
|
||||
// Publisher returns the publisher half of the id.
|
||||
func (p *Plugin) Publisher() string { publisher, _, _ := strings.Cut(p.ID, "."); return publisher }
|
||||
|
||||
// Validate checks the manifest. Every problem is a plain sentence.
|
||||
func (p *Plugin) Validate() []error {
|
||||
var errs []error
|
||||
add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
|
||||
if !idRE.MatchString(p.ID) {
|
||||
add("id %q must be <publisher>.<name>: lowercase letters, digits and hyphens, starting with a letter", p.ID)
|
||||
}
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
add("name is empty")
|
||||
}
|
||||
if strings.TrimSpace(p.Description) == "" {
|
||||
add("description is empty")
|
||||
}
|
||||
if !semverRE.MatchString(p.Version) {
|
||||
add("version %q must be a version like 1.2.0", p.Version)
|
||||
}
|
||||
if !semverRE.MatchString(p.MinAppVersion) {
|
||||
add("min_app_version %q must be a version like 0.5.0", p.MinAppVersion)
|
||||
}
|
||||
if p.HostAPI != HostAPI {
|
||||
add("host_api must be %d", HostAPI)
|
||||
}
|
||||
if !colors[p.Color] {
|
||||
add("color %q is not one of the Cletus colours", p.Color)
|
||||
}
|
||||
if p.Free != nil || p.Capability != "" {
|
||||
add("free and capability are for Eidonix plugins; organizations turn your plugin on instead")
|
||||
}
|
||||
if len(p.HostSeams) > 0 {
|
||||
add("host_seams is for Eidonix plugins; use permissions")
|
||||
}
|
||||
if len(p.UI) > 0 || len(p.UIHostAPI) > 0 {
|
||||
add("screens (ui, ui_host_api) aren't available yet; deliver the feature as chat tools")
|
||||
}
|
||||
if len(p.FileTypes) > 0 {
|
||||
add("file_types need a screen, which isn't available yet")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
nets := 0
|
||||
for _, perm := range p.Permissions {
|
||||
if seen[perm] {
|
||||
add("permission %q is listed twice", perm)
|
||||
}
|
||||
seen[perm] = true
|
||||
if domain, ok := strings.CutPrefix(perm, NetPrefix); ok {
|
||||
nets++
|
||||
if !domainRE.MatchString(domain) {
|
||||
add("%q: a net permission names one host, like net:api.example.com (no wildcards, ports or IP addresses)", perm)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := Permissions[perm]; !ok {
|
||||
add("unknown permission %q; see docs/specification.md, Permissions", perm)
|
||||
}
|
||||
}
|
||||
if nets > MaxNetDomains {
|
||||
add("at most %d net:<domain> permissions", MaxNetDomains)
|
||||
}
|
||||
if seen["chat.handoff"] {
|
||||
add("chat.handoff is for screens, which aren't available yet")
|
||||
}
|
||||
tools := map[string]bool{}
|
||||
for _, t := range p.Tools {
|
||||
if !toolRE.MatchString(t.Name) {
|
||||
add("tool name %q must be lowercase letters, digits and underscores", t.Name)
|
||||
}
|
||||
if tools[t.Name] {
|
||||
add("tool %q is listed twice", t.Name)
|
||||
}
|
||||
tools[t.Name] = true
|
||||
}
|
||||
for name, svc := range p.Provides {
|
||||
if !serviceRE.MatchString(name) {
|
||||
add("service name %q must be lowercase, optionally dotted", name)
|
||||
}
|
||||
if name == "screen" {
|
||||
add("the screen service needs a screen, which isn't available yet")
|
||||
}
|
||||
for method, m := range svc.Methods {
|
||||
if !methodRE.MatchString(method) {
|
||||
add("method %s.%s: names are lowercase letters, digits and underscores", name, method)
|
||||
}
|
||||
for label, schema := range map[string]json.RawMessage{"params": m.Params, "result": m.Result} {
|
||||
var v map[string]any
|
||||
if len(schema) == 0 || json.Unmarshal(schema, &v) != nil {
|
||||
add("method %s.%s: %s must be a JSON schema object", name, method, label)
|
||||
} else if len(schema) > 16<<10 {
|
||||
add("method %s.%s: %s is larger than 16 KiB", name, method, label)
|
||||
} else if strings.Contains(string(schema), `"$ref":"http`) || strings.Contains(string(schema), `"$ref": "http`) {
|
||||
add("method %s.%s: %s may not use a remote $ref", name, method, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, use := range p.Uses {
|
||||
if !useRE.MatchString(use) {
|
||||
add("use %q must be <plugin>:<service>.<method> or <plugin>:<service>.*", use)
|
||||
} else if strings.HasPrefix(use, p.ID+":") {
|
||||
add("a plugin can't use its own service (%s)", use)
|
||||
}
|
||||
}
|
||||
for _, ext := range p.FileTypes {
|
||||
if !extRE.MatchString(ext) {
|
||||
add("file type %q must look like .ext", ext)
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// Allowed reports whether the plugin may call method, and if not, the
|
||||
// permission that would allow it.
|
||||
func (p *Plugin) Allowed(method string) (bool, string) {
|
||||
for _, m := range Always {
|
||||
if m == method {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
for _, perm := range p.Permissions {
|
||||
for _, m := range Permissions[perm].Methods {
|
||||
if m == method {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
for perm, def := range Permissions {
|
||||
for _, m := range def.Methods {
|
||||
if m == method {
|
||||
return false, perm
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Has reports whether the plugin declares perm.
|
||||
func (p *Plugin) Has(perm string) bool {
|
||||
for _, have := range p.Permissions {
|
||||
if have == perm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasNet reports whether any net permission is declared.
|
||||
func (p *Plugin) HasNet() bool {
|
||||
for _, have := range p.Permissions {
|
||||
if have == "net.configured" || strings.HasPrefix(have, NetPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UsedMethods returns, sorted, the host methods the declared permissions unlock.
|
||||
func (p *Plugin) UsedMethods() []string {
|
||||
var out []string
|
||||
for _, perm := range p.Permissions {
|
||||
out = append(out, Permissions[perm].Methods...)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ErrNoManifest is returned when there is no plugin.json.
|
||||
var ErrNoManifest = errors.New("no plugin.json here; run cletus-plugin in the plugin's folder")
|
||||
@@ -0,0 +1,61 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func valid() *Plugin {
|
||||
return &Plugin{ID: "acme.gauge-check", Name: "Gauge Check", Description: "Checks gauges.", Version: "1.0.0",
|
||||
MinAppVersion: "0.5.0", HostAPI: HostAPI, Permissions: []string{"files", "net:api.example.com"},
|
||||
Tools: []Tool{{Name: "gauge_check_run"}}}
|
||||
}
|
||||
|
||||
func TestValidManifest(t *testing.T) {
|
||||
if errs := valid().Validate(); len(errs) != 0 {
|
||||
t.Fatalf("valid manifest refused: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusals(t *testing.T) {
|
||||
cases := map[string]func(*Plugin){
|
||||
"must be <publisher>.<name>": func(p *Plugin) { p.ID = "gauge" },
|
||||
"version": func(p *Plugin) { p.Version = "1.0" },
|
||||
"host_api": func(p *Plugin) { p.HostAPI = 1 },
|
||||
"unknown permission": func(p *Plugin) { p.Permissions = []string{"workspace"} },
|
||||
"no wildcards": func(p *Plugin) { p.Permissions = []string{"net:10.0.0.1"} },
|
||||
"Eidonix plugins": func(p *Plugin) { p.Capability = "quoting" },
|
||||
"aren't available yet": func(p *Plugin) { p.UI = []byte(`{}`) },
|
||||
"listed twice": func(p *Plugin) { p.Tools = append(p.Tools, Tool{Name: "gauge_check_run"}) },
|
||||
"its own service": func(p *Plugin) { p.Uses = []string{"acme.gauge-check:gauge.check"} },
|
||||
}
|
||||
for want, change := range cases {
|
||||
p := valid()
|
||||
change(p)
|
||||
errs := p.Validate()
|
||||
found := false
|
||||
for _, err := range errs {
|
||||
found = found || strings.Contains(err.Error(), want)
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s: got %v", want, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowed(t *testing.T) {
|
||||
p := valid()
|
||||
if ok, _ := p.Allowed("host.files.resolve"); !ok {
|
||||
t.Fatal("files permission did not allow host.files.resolve")
|
||||
}
|
||||
if ok, _ := p.Allowed("host.event"); !ok {
|
||||
t.Fatal("host.event should always be allowed")
|
||||
}
|
||||
ok, need := p.Allowed("host.workspace.list")
|
||||
if ok || need != "workspace.read" {
|
||||
t.Fatalf("workspace.list = %v, %q", ok, need)
|
||||
}
|
||||
if ok, need := p.Allowed("host.code.llm.start"); ok || need != "" {
|
||||
t.Fatalf("first-party method = %v, %q", ok, need)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package pluginsdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Host makes calls to Cletus on behalf of the request being handled. Use
|
||||
// it only inside the handler it was passed to.
|
||||
type Host struct {
|
||||
call func(ctx context.Context, method string, params any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// NewTestHost returns a Host whose calls are answered by fn, for unit tests
|
||||
// of handlers. fn receives the method and the params as JSON.
|
||||
func NewTestHost(fn func(method string, params json.RawMessage) (any, error)) *Host {
|
||||
return &Host{call: func(_ context.Context, method string, params any) (json.RawMessage, error) {
|
||||
raw, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := fn(method, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}}
|
||||
}
|
||||
|
||||
// Call makes any host call in docs/host-api.md and decodes its result into
|
||||
// out (which may be nil).
|
||||
func (h *Host) Call(ctx context.Context, method string, params, out any) error {
|
||||
if params == nil {
|
||||
params = struct{}{}
|
||||
}
|
||||
raw, err := h.call(ctx, method, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("%s: unexpected result: %w", method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolvePath returns the real path of a file the user chose
|
||||
// (host.files.resolve; needs the files permission). Resolve every path
|
||||
// before reading it.
|
||||
func (h *Host) ResolvePath(ctx context.Context, path string) (string, error) {
|
||||
var resolved string
|
||||
err := h.Call(ctx, "host.files.resolve", map[string]string{"path": path}, &resolved)
|
||||
return resolved, err
|
||||
}
|
||||
|
||||
// CanWrite reports whether the user's current rules allow writing path
|
||||
// (host.files.can_write; needs the files permission).
|
||||
func (h *Host) CanWrite(ctx context.Context, path string) (bool, error) {
|
||||
var allowed bool
|
||||
err := h.Call(ctx, "host.files.can_write", map[string]string{"path": path}, &allowed)
|
||||
return allowed, err
|
||||
}
|
||||
|
||||
// Event sends an event to the plugin's screen. name must start with the
|
||||
// plugin id and a colon ("acme.gauge-check:progress").
|
||||
func (h *Host) Event(ctx context.Context, name string, args ...any) error {
|
||||
if args == nil {
|
||||
args = []any{}
|
||||
}
|
||||
return h.Call(ctx, "host.event", map[string]any{"name": name, "args": args}, nil)
|
||||
}
|
||||
|
||||
// Published is a link to a file in the data folder that the screen can fetch.
|
||||
type Published struct {
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// Publish makes a file in the data folder fetchable by the screen for ttl
|
||||
// (1 second to 1 hour). The file must not change while published.
|
||||
func (h *Host) Publish(ctx context.Context, path, contentType string, ttl time.Duration) (Published, error) {
|
||||
var out Published
|
||||
err := h.Call(ctx, "host.data.publish", map[string]any{"path": path, "contentType": contentType, "ttl": int(ttl / time.Second)}, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// Package pluginsdk is the worker side of a Cletus plugin: the message
|
||||
// protocol, chat tools, screen and service handlers, and calls to the host.
|
||||
// See docs/specification.md and docs/host-api.md.
|
||||
package pluginsdk
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// APIVersion is the worker protocol version the host expects at handshake.
|
||||
const APIVersion = 1
|
||||
|
||||
// MaxMessageBytes is the largest message (one line) either side may send.
|
||||
const MaxMessageBytes = 64 << 20
|
||||
|
||||
// Func handles one screen (ui.*) request. params is the JSON array of the
|
||||
// screen's arguments; decode it with Args.
|
||||
type Func func(ctx context.Context, h *Host, params json.RawMessage) (any, error)
|
||||
|
||||
// ServiceFunc handles one service method another plugin calls.
|
||||
type ServiceFunc func(ctx context.Context, h *Host, call ServiceCall) (any, error)
|
||||
|
||||
// ServiceCall is a service request the host routed from another plugin.
|
||||
type ServiceCall struct {
|
||||
Caller string `json:"caller"`
|
||||
Mutating bool `json:"mutating"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
}
|
||||
|
||||
// Handlers is everything a worker answers.
|
||||
type Handlers struct {
|
||||
// Tools are the chat tools. Each must also be listed in plugin.json.
|
||||
Tools []Tool
|
||||
// UI maps a screen method name ("check", answered as ui.check) to its handler.
|
||||
UI map[string]Func
|
||||
// Services maps "<service>.<method>" ("gauge.check") to its handler.
|
||||
// Each must be declared under "provides" in plugin.json.
|
||||
Services map[string]ServiceFunc
|
||||
// Busy reports whether work is running that must not be interrupted.
|
||||
// Nil means never busy.
|
||||
Busy func() bool
|
||||
}
|
||||
|
||||
// ID returns the plugin id the host started the worker as.
|
||||
func ID() string { return os.Getenv("CLETUS_PLUGIN_ID") }
|
||||
|
||||
// DataDir returns the worker's data folder for the signed-in account. It is
|
||||
// also the worker's working directory. Empty when not started by a host.
|
||||
func DataDir() string { return os.Getenv("CLETUS_PLUGIN_DATA") }
|
||||
|
||||
// Serve runs the worker on standard input and output until the host shuts
|
||||
// it down or closes the pipe. Standard output belongs to the protocol, so
|
||||
// Serve points os.Stdout at standard error: a stray fmt.Println lands in
|
||||
// the worker log instead of breaking the connection.
|
||||
func Serve(h Handlers) error {
|
||||
out := os.Stdout
|
||||
os.Stdout = os.Stderr
|
||||
return ServeIO(context.Background(), os.Stdin, out, h)
|
||||
}
|
||||
|
||||
type message struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *wireError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type wireError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type server struct {
|
||||
handlers Handlers
|
||||
tools map[string]Tool
|
||||
out io.Writer
|
||||
writeMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
active map[string]context.CancelFunc
|
||||
pending map[string]chan message
|
||||
next int
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// ServeIO runs the worker on the given streams. It returns when ctx ends,
|
||||
// the input closes, or the host sends plugin.shutdown.
|
||||
func ServeIO(ctx context.Context, in io.Reader, out io.Writer, h Handlers) error {
|
||||
s := &server{handlers: h, tools: map[string]Tool{}, out: out,
|
||||
active: map[string]context.CancelFunc{}, pending: map[string]chan message{}}
|
||||
for _, t := range h.Tools {
|
||||
if _, dup := s.tools[t.Name]; dup {
|
||||
return fmt.Errorf("tool %q is registered twice", t.Name)
|
||||
}
|
||||
s.tools[t.Name] = t
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
defer s.wg.Wait()
|
||||
defer s.cancelAll()
|
||||
|
||||
lines := make(chan []byte)
|
||||
readErr := make(chan error, 1)
|
||||
go func() {
|
||||
reader := bufio.NewReaderSize(in, 64<<10)
|
||||
for {
|
||||
line, err := readLine(reader)
|
||||
if len(line) > 0 {
|
||||
select {
|
||||
case lines <- line:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
readErr <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-readErr:
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case line := <-lines:
|
||||
var m message
|
||||
if err := json.Unmarshal(line, &m); err != nil {
|
||||
return fmt.Errorf("invalid message from host: %w", err)
|
||||
}
|
||||
if stop := s.dispatch(ctx, m); stop {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readLine(r *bufio.Reader) ([]byte, error) {
|
||||
var line []byte
|
||||
for {
|
||||
chunk, err := r.ReadSlice('\n')
|
||||
line = append(line, chunk...)
|
||||
if len(line) > MaxMessageBytes+1 {
|
||||
return nil, errors.New("message from host is too large")
|
||||
}
|
||||
if err == bufio.ErrBufferFull {
|
||||
continue
|
||||
}
|
||||
return []byte(strings.TrimSpace(string(line))), err
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch handles one incoming message and reports whether to stop.
|
||||
func (s *server) dispatch(ctx context.Context, m message) bool {
|
||||
switch {
|
||||
case m.Method == "$/cancel":
|
||||
var id string
|
||||
if json.Unmarshal(m.Params, &id) == nil {
|
||||
s.mu.Lock()
|
||||
if cancel := s.active[id]; cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
case m.Method == "plugin.shutdown":
|
||||
s.reply(m.ID, map[string]bool{"ok": true}, nil)
|
||||
return true
|
||||
case m.Method != "":
|
||||
if m.ID == "" {
|
||||
return false // a notification this worker doesn't use
|
||||
}
|
||||
reqCtx, cancel := context.WithCancel(ctx)
|
||||
s.mu.Lock()
|
||||
s.active[m.ID] = cancel
|
||||
s.mu.Unlock()
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.active, m.ID)
|
||||
s.mu.Unlock()
|
||||
cancel()
|
||||
}()
|
||||
result, err := s.handle(reqCtx, m)
|
||||
s.reply(m.ID, result, err)
|
||||
}()
|
||||
case m.ID != "":
|
||||
s.mu.Lock()
|
||||
ch := s.pending[m.ID]
|
||||
delete(s.pending, m.ID)
|
||||
s.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *server) handle(ctx context.Context, m message) (result any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
result, err = nil, fmt.Errorf("the plugin failed while handling %s: %v", m.Method, r)
|
||||
}
|
||||
}()
|
||||
h := &Host{call: func(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
return s.callHost(ctx, m.ID, method, params)
|
||||
}}
|
||||
switch {
|
||||
case m.Method == "plugin.handshake":
|
||||
return map[string]int{"api_version": APIVersion}, nil
|
||||
case m.Method == "plugin.busy":
|
||||
return s.handlers.Busy != nil && s.handlers.Busy(), nil
|
||||
case m.Method == "tools.list":
|
||||
return s.listTools(), nil
|
||||
case m.Method == "tools.invoke":
|
||||
return s.invokeTool(ctx, h, m.Params)
|
||||
case strings.HasPrefix(m.Method, "ui."):
|
||||
fn := s.handlers.UI[strings.TrimPrefix(m.Method, "ui.")]
|
||||
if fn == nil {
|
||||
return nil, fmt.Errorf("unknown method %s", m.Method)
|
||||
}
|
||||
return fn(ctx, h, m.Params)
|
||||
case strings.HasPrefix(m.Method, "svc."):
|
||||
fn := s.handlers.Services[strings.TrimPrefix(m.Method, "svc.")]
|
||||
if fn == nil {
|
||||
return nil, fmt.Errorf("unknown method %s", m.Method)
|
||||
}
|
||||
var call ServiceCall
|
||||
if err := json.Unmarshal(m.Params, &call); err != nil {
|
||||
return nil, fmt.Errorf("invalid service request: %w", err)
|
||||
}
|
||||
return fn(ctx, h, call)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown method %s", m.Method)
|
||||
}
|
||||
|
||||
func (s *server) reply(id string, result any, err error) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
m := message{ID: id}
|
||||
if err != nil {
|
||||
m.Error = &wireError{Message: err.Error()}
|
||||
} else {
|
||||
raw, marshalErr := json.Marshal(result)
|
||||
if marshalErr != nil {
|
||||
m.Error = &wireError{Message: "the plugin returned a result that is not JSON: " + marshalErr.Error()}
|
||||
} else {
|
||||
m.Result = raw
|
||||
}
|
||||
}
|
||||
s.send(m)
|
||||
}
|
||||
|
||||
func (s *server) send(m message) {
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil || len(data) > MaxMessageBytes {
|
||||
if m.ID != "" && m.Method == "" {
|
||||
data, _ = json.Marshal(message{ID: m.ID, Error: &wireError{Message: "the plugin's reply is too large"}})
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
_, _ = s.out.Write(append(data, '\n'))
|
||||
}
|
||||
|
||||
func (s *server) callHost(ctx context.Context, parent, method string, params any) (json.RawMessage, error) {
|
||||
raw, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", method, err)
|
||||
}
|
||||
ch := make(chan message, 1)
|
||||
s.mu.Lock()
|
||||
s.next++
|
||||
id := fmt.Sprintf("w%d", s.next)
|
||||
s.pending[id] = ch
|
||||
s.mu.Unlock()
|
||||
s.send(message{ID: id, ParentID: parent, Method: method, Params: raw})
|
||||
select {
|
||||
case reply := <-ch:
|
||||
if reply.Error != nil {
|
||||
return nil, errors.New(reply.Error.Message)
|
||||
}
|
||||
return reply.Result, nil
|
||||
case <-ctx.Done():
|
||||
s.mu.Lock()
|
||||
delete(s.pending, id)
|
||||
s.mu.Unlock()
|
||||
cancel, _ := json.Marshal(id)
|
||||
s.send(message{Method: "$/cancel", Params: cancel})
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) cancelAll() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, cancel := range s.active {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Args decodes a screen request's argument array into targets, in order.
|
||||
// Missing trailing arguments leave their targets unchanged.
|
||||
func Args(params json.RawMessage, targets ...any) error {
|
||||
var items []json.RawMessage
|
||||
if len(params) > 0 && string(params) != "null" {
|
||||
if err := json.Unmarshal(params, &items); err != nil {
|
||||
return fmt.Errorf("screen arguments must be a JSON array: %w", err)
|
||||
}
|
||||
}
|
||||
for i, target := range targets {
|
||||
if i >= len(items) {
|
||||
break
|
||||
}
|
||||
if err := json.Unmarshal(items[i], target); err != nil {
|
||||
return fmt.Errorf("argument %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package pluginsdk
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pipeHost drives a worker over in-memory pipes, as Cletus does.
|
||||
type pipeHost struct {
|
||||
t *testing.T
|
||||
toW *io.PipeWriter
|
||||
fromW *bufio.Scanner
|
||||
done chan error
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func startWorker(t *testing.T, h Handlers) *pipeHost {
|
||||
t.Helper()
|
||||
inR, inW := io.Pipe()
|
||||
outR, outW := io.Pipe()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- ServeIO(ctx, inR, outW, h); outW.Close() }()
|
||||
sc := bufio.NewScanner(outR)
|
||||
sc.Buffer(make([]byte, 1<<20), MaxMessageBytes)
|
||||
p := &pipeHost{t: t, toW: inW, fromW: sc, done: done, cancel: cancel}
|
||||
t.Cleanup(func() { cancel(); inW.Close() })
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *pipeHost) send(m message) {
|
||||
p.t.Helper()
|
||||
data, _ := json.Marshal(m)
|
||||
if _, err := p.toW.Write(append(data, '\n')); err != nil {
|
||||
p.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pipeHost) read() message {
|
||||
p.t.Helper()
|
||||
if !p.fromW.Scan() {
|
||||
p.t.Fatalf("worker closed: %v", p.fromW.Err())
|
||||
}
|
||||
var m message
|
||||
if err := json.Unmarshal(p.fromW.Bytes(), &m); err != nil {
|
||||
p.t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestHandshakeAndShutdown(t *testing.T) {
|
||||
p := startWorker(t, Handlers{})
|
||||
p.send(message{ID: "1", Method: "plugin.handshake"})
|
||||
if got := p.read(); string(got.Result) != `{"api_version":1}` {
|
||||
t.Fatalf("handshake = %s", got.Result)
|
||||
}
|
||||
p.send(message{ID: "2", Method: "plugin.shutdown"})
|
||||
if got := p.read(); got.ID != "2" || got.Error != nil {
|
||||
t.Fatalf("shutdown reply = %+v", got)
|
||||
}
|
||||
select {
|
||||
case err := <-p.done:
|
||||
if err != nil {
|
||||
t.Fatalf("ServeIO = %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("worker did not stop after plugin.shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolWithHostCall(t *testing.T) {
|
||||
tool := Tool{Name: "demo_read", Description: "Reads a file.", Run: func(ctx context.Context, h *Host, call ToolCall) ToolResult {
|
||||
var args struct{ Path string }
|
||||
if err := call.Decode(&args); err != nil {
|
||||
return Fail("%v", err)
|
||||
}
|
||||
path, err := h.ResolvePath(ctx, args.Path)
|
||||
if err != nil {
|
||||
return Fail("%v", err)
|
||||
}
|
||||
return Text("resolved %s", path)
|
||||
}}
|
||||
p := startWorker(t, Handlers{Tools: []Tool{tool}})
|
||||
|
||||
p.send(message{ID: "1", Method: "tools.list"})
|
||||
var list []toolDescriptor
|
||||
if err := json.Unmarshal(p.read().Result, &list); err != nil || len(list) != 1 || list[0].Def.Function.Name != "demo_read" {
|
||||
t.Fatalf("tools.list = %+v, %v", list, err)
|
||||
}
|
||||
|
||||
params, _ := json.Marshal(toolInvocation{Name: "demo_read", Args: `{"Path":"a.txt"}`})
|
||||
p.send(message{ID: "2", Method: "tools.invoke", Params: params})
|
||||
call := p.read()
|
||||
if call.Method != "host.files.resolve" || call.ParentID != "2" || !strings.Contains(string(call.Params), "a.txt") {
|
||||
t.Fatalf("host call = %+v", call)
|
||||
}
|
||||
p.send(message{ID: call.ID, Result: json.RawMessage(`"/work/a.txt"`)})
|
||||
var result ToolResult
|
||||
if err := json.Unmarshal(p.read().Result, &result); err != nil || result.Text != "resolved /work/a.txt" {
|
||||
t.Fatalf("result = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelStopsHandler(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
p := startWorker(t, Handlers{UI: map[string]Func{"wait": func(ctx context.Context, h *Host, params json.RawMessage) (any, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}}})
|
||||
p.send(message{ID: "9", Method: "ui.wait", Params: json.RawMessage(`[]`)})
|
||||
<-started
|
||||
cancelID, _ := json.Marshal("9")
|
||||
p.send(message{Method: "$/cancel", Params: cancelID})
|
||||
if got := p.read(); got.ID != "9" || got.Error == nil {
|
||||
t.Fatalf("cancelled reply = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanicBecomesError(t *testing.T) {
|
||||
p := startWorker(t, Handlers{UI: map[string]Func{"boom": func(context.Context, *Host, json.RawMessage) (any, error) {
|
||||
panic("bad input")
|
||||
}}})
|
||||
p.send(message{ID: "1", Method: "ui.boom"})
|
||||
if got := p.read(); got.Error == nil || !strings.Contains(got.Error.Message, "bad input") {
|
||||
t.Fatalf("reply = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAndUnknownMethod(t *testing.T) {
|
||||
p := startWorker(t, Handlers{Services: map[string]ServiceFunc{"gauge.check": func(_ context.Context, _ *Host, call ServiceCall) (any, error) {
|
||||
return map[string]string{"caller": call.Caller}, nil
|
||||
}}})
|
||||
p.send(message{ID: "1", Method: "svc.gauge.check", Params: json.RawMessage(`{"caller":"acme.other","args":{}}`)})
|
||||
if got := p.read(); string(got.Result) != `{"caller":"acme.other"}` {
|
||||
t.Fatalf("service = %+v", got)
|
||||
}
|
||||
p.send(message{ID: "2", Method: "nope"})
|
||||
if got := p.read(); got.Error == nil {
|
||||
t.Fatalf("unknown method answered %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgs(t *testing.T) {
|
||||
var name string
|
||||
var count int
|
||||
if err := Args(json.RawMessage(`["x", 3]`), &name, &count); err != nil || name != "x" || count != 3 {
|
||||
t.Fatalf("Args = %q %d %v", name, count, err)
|
||||
}
|
||||
if err := Args(json.RawMessage(`{"a":1}`), &name); err == nil {
|
||||
t.Fatal("Args accepted an object")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTestHost(t *testing.T) {
|
||||
h := NewTestHost(func(method string, params json.RawMessage) (any, error) {
|
||||
return method == "host.files.can_write", nil
|
||||
})
|
||||
ok, err := h.CanWrite(context.Background(), "x")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CanWrite = %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package pluginsdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Tool is a chat tool: a function Cletus's AI can call. Name, Mutating and
|
||||
// RequiresApproval must match the plugin's entry in plugin.json "tools".
|
||||
type Tool struct {
|
||||
Name string
|
||||
Description string
|
||||
// Parameters is the JSON schema of the arguments (an object schema).
|
||||
Parameters map[string]any
|
||||
// Mutating is true if the tool writes anything anywhere.
|
||||
Mutating bool
|
||||
// RequiresApproval makes Cletus ask the user before every call.
|
||||
RequiresApproval bool
|
||||
Run func(ctx context.Context, h *Host, call ToolCall) ToolResult
|
||||
}
|
||||
|
||||
// ToolCall is one call of a chat tool.
|
||||
type ToolCall struct {
|
||||
// Args is the JSON object of arguments the AI sent.
|
||||
Args json.RawMessage
|
||||
// WorkingDir is the user's working folder ("" when none is open).
|
||||
WorkingDir string
|
||||
// EditMode is false when the user has Cletus read-only: a mutating tool
|
||||
// must check Host.CanWrite before each write and refuse when it's false.
|
||||
EditMode bool
|
||||
}
|
||||
|
||||
// Decode decodes the arguments into v.
|
||||
func (c ToolCall) Decode(v any) error {
|
||||
if len(c.Args) == 0 {
|
||||
return json.Unmarshal([]byte("{}"), v)
|
||||
}
|
||||
if err := json.Unmarshal(c.Args, v); err != nil {
|
||||
return fmt.Errorf("the arguments don't match the tool's parameters: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToolResult is what the AI reads back. Set Text on success, Error on
|
||||
// failure (a plain sentence with the next step). Images are optional
|
||||
// (PNG or JPEG bytes).
|
||||
type ToolResult struct {
|
||||
Text string `json:"Text,omitempty"`
|
||||
Error string `json:"Error,omitempty"`
|
||||
Images [][]byte `json:"Images,omitempty"`
|
||||
}
|
||||
|
||||
// Text returns a successful result.
|
||||
func Text(format string, args ...any) ToolResult {
|
||||
return ToolResult{Text: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// Fail returns a failed result.
|
||||
func Fail(format string, args ...any) ToolResult {
|
||||
return ToolResult{Error: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
type toolDescriptor struct {
|
||||
Def toolDef `json:"def"`
|
||||
Mutating bool `json:"mutating"`
|
||||
RequiresApproval bool `json:"requires_approval"`
|
||||
}
|
||||
|
||||
type toolDef struct {
|
||||
Type string `json:"type"`
|
||||
Function functionDef `json:"function"`
|
||||
}
|
||||
|
||||
type functionDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
func (s *server) listTools() []toolDescriptor {
|
||||
out := make([]toolDescriptor, 0, len(s.tools))
|
||||
for _, t := range s.tools {
|
||||
params := t.Parameters
|
||||
if params == nil {
|
||||
params = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
}
|
||||
out = append(out, toolDescriptor{
|
||||
Def: toolDef{Type: "function", Function: functionDef{Name: t.Name, Description: t.Description, Parameters: params}},
|
||||
Mutating: t.Mutating,
|
||||
RequiresApproval: t.RequiresApproval,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Def.Function.Name < out[j].Def.Function.Name })
|
||||
return out
|
||||
}
|
||||
|
||||
type toolInvocation struct {
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
EditMode bool `json:"edit_mode"`
|
||||
}
|
||||
|
||||
func (s *server) invokeTool(ctx context.Context, h *Host, params json.RawMessage) (ToolResult, error) {
|
||||
var inv toolInvocation
|
||||
if err := json.Unmarshal(params, &inv); err != nil {
|
||||
return ToolResult{}, fmt.Errorf("invalid tool call: %w", err)
|
||||
}
|
||||
t, ok := s.tools[inv.Name]
|
||||
if !ok || t.Run == nil {
|
||||
return Fail("This plugin has no tool named %q.", inv.Name), nil
|
||||
}
|
||||
return t.Run(ctx, h, ToolCall{Args: json.RawMessage(inv.Args), WorkingDir: inv.WorkingDir, EditMode: inv.EditMode}), nil
|
||||
}
|
||||
Reference in new issue
Block a user