From 9b19d3a8be12212547b6bc3ad978ec78fb60cf66 Mon Sep 17 00:00:00 2001 From: Eidonix <> Date: Tue, 10 Nov 2026 19:50:09 -0500 Subject: [PATCH] Cletus plugin kit: rules for coding agents, specification, host API, style guide, worker SDK and cletus-plugin tool --- AGENTS.md | 107 ++++ CLAUDE.md | 1 + LICENSE | 201 ++++++++ NOTICE | 5 + README.md | 73 +++ docs/host-api.md | 82 +++ docs/patterns.md | 73 +++ docs/review-checklist.md | 55 ++ docs/specification.md | 283 ++++++++++ docs/style-guide.md | 134 +++++ docs/submission.md | 101 ++++ sdk/LICENSE | 201 ++++++++ sdk/NOTICE | 5 + sdk/cmd/cletus-plugin/build.go | 51 ++ sdk/cmd/cletus-plugin/check.go | 483 ++++++++++++++++++ sdk/cmd/cletus-plugin/host.go | 323 ++++++++++++ sdk/cmd/cletus-plugin/host_test.go | 101 ++++ sdk/cmd/cletus-plugin/main.go | 61 +++ sdk/cmd/cletus-plugin/new.go | 115 +++++ sdk/cmd/cletus-plugin/run.go | 134 +++++ sdk/cmd/cletus-plugin/template/AGENTS.md.tmpl | 21 + .../cletus-plugin/template/CHANGELOG.md.tmpl | 5 + sdk/cmd/cletus-plugin/template/CLAUDE.md.tmpl | 1 + .../template/_dot_gitignore.tmpl | 2 + .../template/cmd/worker/main.go.tmpl | 18 + .../cletus-plugin/template/docs/guide.md.tmpl | 8 + .../cletus-plugin/template/docs/plan.md.tmpl | 25 + sdk/cmd/cletus-plugin/template/go.mod.tmpl | 5 + .../template/internal/tools/tools.go.tmpl | 88 ++++ .../internal/tools/tools_test.go.tmpl | 73 +++ .../template/listing/listing.json.tmpl | 8 + .../cletus-plugin/template/plugin.json.tmpl | 14 + .../template/testdata/sample.txt.tmpl | 3 + sdk/cmd/cletus-plugin/workspace.go | 190 +++++++ sdk/go.mod | 3 + sdk/manifest/manifest.go | 296 +++++++++++ sdk/manifest/manifest_test.go | 61 +++ sdk/pluginsdk/host.go | 90 ++++ sdk/pluginsdk/serve.go | 337 ++++++++++++ sdk/pluginsdk/serve_test.go | 168 ++++++ sdk/pluginsdk/tools.go | 116 +++++ 41 files changed, 4121 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100644 docs/host-api.md create mode 100644 docs/patterns.md create mode 100644 docs/review-checklist.md create mode 100644 docs/specification.md create mode 100644 docs/style-guide.md create mode 100644 docs/submission.md create mode 100644 sdk/LICENSE create mode 100644 sdk/NOTICE create mode 100644 sdk/cmd/cletus-plugin/build.go create mode 100644 sdk/cmd/cletus-plugin/check.go create mode 100644 sdk/cmd/cletus-plugin/host.go create mode 100644 sdk/cmd/cletus-plugin/host_test.go create mode 100644 sdk/cmd/cletus-plugin/main.go create mode 100644 sdk/cmd/cletus-plugin/new.go create mode 100644 sdk/cmd/cletus-plugin/run.go create mode 100644 sdk/cmd/cletus-plugin/template/AGENTS.md.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/CHANGELOG.md.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/CLAUDE.md.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/_dot_gitignore.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/cmd/worker/main.go.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/docs/guide.md.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/docs/plan.md.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/go.mod.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/internal/tools/tools.go.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/internal/tools/tools_test.go.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/listing/listing.json.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/plugin.json.tmpl create mode 100644 sdk/cmd/cletus-plugin/template/testdata/sample.txt.tmpl create mode 100644 sdk/cmd/cletus-plugin/workspace.go create mode 100644 sdk/go.mod create mode 100644 sdk/manifest/manifest.go create mode 100644 sdk/manifest/manifest_test.go create mode 100644 sdk/pluginsdk/host.go create mode 100644 sdk/pluginsdk/serve.go create mode 100644 sdk/pluginsdk/serve_test.go create mode 100644 sdk/pluginsdk/tools.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..eb7368a --- /dev/null +++ b/AGENTS.md @@ -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:` 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..1ab10ab --- /dev/null +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6795507 --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/host-api.md b/docs/host-api.md new file mode 100644 index 0000000..ed952c5 --- /dev/null +++ b/docs/host-api.md @@ -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 `:` | +| `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:/files/`. 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 `:agent-progress` and `: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). diff --git a/docs/patterns.md b/docs/patterns.md new file mode 100644 index 0000000..8b231c0 --- /dev/null +++ b/docs/patterns.md @@ -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 `: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:` 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. diff --git a/docs/review-checklist.md b/docs/review-checklist.md new file mode 100644 index 0000000..af74c3c --- /dev/null +++ b/docs/review-checklist.md @@ -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. diff --git a/docs/specification.md b/docs/specification.md new file mode 100644 index 0000000..4ceb52d --- /dev/null +++ b/docs/specification.md @@ -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` | `.`. Eidonix assigns your publisher id. ``: 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:` | Connect to | 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": , "mutating": bool, "requires_approval": bool}]` | +| `tools.invoke` | `{"name", "args", "working_dir", "edit_mode"}` (`args` is JSON text) | `{"Text": "…"}` or `{"Error": "…"}`; `"Images": [base64]` optional | +| `ui.` | 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..` | `{"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 `_`. +- 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 + `:` 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 `:.` or + `:.*`. +- `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 | diff --git a/docs/style-guide.md b/docs/style-guide.md new file mode 100644 index 0000000..9b9e6e9 --- /dev/null +++ b/docs/style-guide.md @@ -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 " 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 `_[_]` (`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. diff --git a/docs/submission.md b/docs/submission.md new file mode 100644 index 0000000..10ed3e4 --- /dev/null +++ b/docs/submission.md @@ -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.` 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` 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. diff --git a/sdk/LICENSE b/sdk/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/sdk/LICENSE @@ -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. diff --git a/sdk/NOTICE b/sdk/NOTICE new file mode 100644 index 0000000..1ab10ab --- /dev/null +++ b/sdk/NOTICE @@ -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. diff --git a/sdk/cmd/cletus-plugin/build.go b/sdk/cmd/cletus-plugin/build.go new file mode 100644 index 0000000..f02e5d7 --- /dev/null +++ b/sdk/cmd/cletus-plugin/build.go @@ -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 +} diff --git a/sdk/cmd/cletus-plugin/check.go b/sdk/cmd/cletus-plugin/check.go new file mode 100644 index 0000000..49d16c9 --- /dev/null +++ b/sdk/cmd/cletus-plugin/check.go @@ -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() +} diff --git a/sdk/cmd/cletus-plugin/host.go b/sdk/cmd/cletus-plugin/host.go new file mode 100644 index 0000000..36ca556 --- /dev/null +++ b/sdk/cmd/cletus-plugin/host.go @@ -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) +} diff --git a/sdk/cmd/cletus-plugin/host_test.go b/sdk/cmd/cletus-plugin/host_test.go new file mode 100644 index 0000000..23580f7 --- /dev/null +++ b/sdk/cmd/cletus-plugin/host_test.go @@ -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) + } +} diff --git a/sdk/cmd/cletus-plugin/main.go b/sdk/cmd/cletus-plugin/main.go new file mode 100644 index 0000000..ba106ce --- /dev/null +++ b/sdk/cmd/cletus-plugin/main.go @@ -0,0 +1,61 @@ +// Command cletus-plugin creates, checks and runs Cletus plugins. +// +// cletus-plugin new . [folder] +// cletus-plugin check +// cletus-plugin run [flags] tools | invoke [json] | ui [json array] | service [json] +package main + +import ( + "fmt" + "os" +) + +// sdkVersion is the SDK release new plugins require (tag sdk/). +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 . [folder] create a plugin from the template + cletus-plugin check run the store's automated checks here + cletus-plugin run [flags] run the worker under the test host + tools list the chat tools + invoke [json] call a chat tool + ui [json array] call a screen method + service [json] call a provided service + flags: -files the folder the user "opened" (default: testdata) + -workspace simulate one shared folder backed by this directory + -edit the user allows edits (default: read-only) + -capability a Cletus feature the user has (repeatable) + cletus-plugin version + +Docs: https://git.eidonix.com/eidonix/cletus-plugins +`) +} diff --git a/sdk/cmd/cletus-plugin/new.go b/sdk/cmd/cletus-plugin/new.go new file mode 100644 index 0000000..1d5c35f --- /dev/null +++ b/sdk/cmd/cletus-plugin/new.go @@ -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 . [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, " ") +} diff --git a/sdk/cmd/cletus-plugin/run.go b/sdk/cmd/cletus-plugin/run.go new file mode 100644 index 0000000..8b0404e --- /dev/null +++ b/sdk/cmd/cletus-plugin/run.go @@ -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 .") + } + 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 +} diff --git a/sdk/cmd/cletus-plugin/template/AGENTS.md.tmpl b/sdk/cmd/cletus-plugin/template/AGENTS.md.tmpl new file mode 100644 index 0000000..3472231 --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/AGENTS.md.tmpl @@ -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 '' + go test ./... + +Keep docs/plan.md, plugin.json and CHANGELOG.md in step with the code. diff --git a/sdk/cmd/cletus-plugin/template/CHANGELOG.md.tmpl b/sdk/cmd/cletus-plugin/template/CHANGELOG.md.tmpl new file mode 100644 index 0000000..755ef82 --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/CHANGELOG.md.tmpl @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- First version: count the lines in a text file, and save a summary next to it. diff --git a/sdk/cmd/cletus-plugin/template/CLAUDE.md.tmpl b/sdk/cmd/cletus-plugin/template/CLAUDE.md.tmpl new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/CLAUDE.md.tmpl @@ -0,0 +1 @@ +@AGENTS.md diff --git a/sdk/cmd/cletus-plugin/template/_dot_gitignore.tmpl b/sdk/cmd/cletus-plugin/template/_dot_gitignore.tmpl new file mode 100644 index 0000000..e3e380b --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/_dot_gitignore.tmpl @@ -0,0 +1,2 @@ +/.cletus-dev/ +/testdata/.cletus/ diff --git a/sdk/cmd/cletus-plugin/template/cmd/worker/main.go.tmpl b/sdk/cmd/cletus-plugin/template/cmd/worker/main.go.tmpl new file mode 100644 index 0000000..2f1e7d5 --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/cmd/worker/main.go.tmpl @@ -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) + } +} diff --git a/sdk/cmd/cletus-plugin/template/docs/guide.md.tmpl b/sdk/cmd/cletus-plugin/template/docs/guide.md.tmpl new file mode 100644 index 0000000..2fca35a --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/docs/guide.md.tmpl @@ -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. diff --git a/sdk/cmd/cletus-plugin/template/docs/plan.md.tmpl b/sdk/cmd/cletus-plugin/template/docs/plan.md.tmpl new file mode 100644 index 0000000..7bd4674 --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/docs/plan.md.tmpl @@ -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 `.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. diff --git a/sdk/cmd/cletus-plugin/template/go.mod.tmpl b/sdk/cmd/cletus-plugin/template/go.mod.tmpl new file mode 100644 index 0000000..bed3ef5 --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/go.mod.tmpl @@ -0,0 +1,5 @@ +module [[.Module]] + +go 1.24 + +require [[.SDKModule]] [[.SDKVersion]] diff --git a/sdk/cmd/cletus-plugin/template/internal/tools/tools.go.tmpl b/sdk/cmd/cletus-plugin/template/internal/tools/tools.go.tmpl new file mode 100644 index 0000000..60448c9 --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/internal/tools/tools.go.tmpl @@ -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 .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 +} diff --git a/sdk/cmd/cletus-plugin/template/internal/tools/tools_test.go.tmpl b/sdk/cmd/cletus-plugin/template/internal/tools/tools_test.go.tmpl new file mode 100644 index 0000000..0c6619b --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/internal/tools/tools_test.go.tmpl @@ -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) + } +} diff --git a/sdk/cmd/cletus-plugin/template/listing/listing.json.tmpl b/sdk/cmd/cletus-plugin/template/listing/listing.json.tmpl new file mode 100644 index 0000000..d94a22c --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/listing/listing.json.tmpl @@ -0,0 +1,8 @@ +{ + "summary": "Counts the lines in a text file.", + "category": "Productivity", + "visibility": "store", + "organizations": [], + "support": "", + "price": "free" +} diff --git a/sdk/cmd/cletus-plugin/template/plugin.json.tmpl b/sdk/cmd/cletus-plugin/template/plugin.json.tmpl new file mode 100644 index 0000000..b13ed8f --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/plugin.json.tmpl @@ -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} + ] +} diff --git a/sdk/cmd/cletus-plugin/template/testdata/sample.txt.tmpl b/sdk/cmd/cletus-plugin/template/testdata/sample.txt.tmpl new file mode 100644 index 0000000..20aeba2 --- /dev/null +++ b/sdk/cmd/cletus-plugin/template/testdata/sample.txt.tmpl @@ -0,0 +1,3 @@ +first line +second line +third line diff --git a/sdk/cmd/cletus-plugin/workspace.go b/sdk/cmd/cletus-plugin/workspace.go new file mode 100644 index 0000000..131f60d --- /dev/null +++ b/sdk/cmd/cletus-plugin/workspace.go @@ -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 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/)", 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() +} diff --git a/sdk/go.mod b/sdk/go.mod new file mode 100644 index 0000000..dbb632f --- /dev/null +++ b/sdk/go.mod @@ -0,0 +1,3 @@ +module git.eidonix.com/eidonix/cletus-plugins/sdk + +go 1.24 diff --git a/sdk/manifest/manifest.go b/sdk/manifest/manifest.go new file mode 100644 index 0000000..e98191e --- /dev/null +++ b/sdk/manifest/manifest.go @@ -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: 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: permission. +const NetPrefix = "net:" + +// MaxNetDomains bounds the net: 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 .: 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: 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 :. or :.*", 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") diff --git a/sdk/manifest/manifest_test.go b/sdk/manifest/manifest_test.go new file mode 100644 index 0000000..b8fa076 --- /dev/null +++ b/sdk/manifest/manifest_test.go @@ -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 .": 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) + } +} diff --git a/sdk/pluginsdk/host.go b/sdk/pluginsdk/host.go new file mode 100644 index 0000000..c0fa0e9 --- /dev/null +++ b/sdk/pluginsdk/host.go @@ -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 +} diff --git a/sdk/pluginsdk/serve.go b/sdk/pluginsdk/serve.go new file mode 100644 index 0000000..3a11b7b --- /dev/null +++ b/sdk/pluginsdk/serve.go @@ -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 "." ("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 +} diff --git a/sdk/pluginsdk/serve_test.go b/sdk/pluginsdk/serve_test.go new file mode 100644 index 0000000..890f575 --- /dev/null +++ b/sdk/pluginsdk/serve_test.go @@ -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) + } +} diff --git a/sdk/pluginsdk/tools.go b/sdk/pluginsdk/tools.go new file mode 100644 index 0000000..1a13b20 --- /dev/null +++ b/sdk/pluginsdk/tools.go @@ -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 +}