@zhama/tego-plugin-sdk 0.0.0-stage → 1.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +172 -0
- package/README.md +846 -2
- package/access-catalog.schema.json +304 -0
- package/access-fixtures/resolve.json +1359 -0
- package/bin/tego-plugin-pack.mjs +2 -0
- package/bin/tego-plugin.mjs +3 -0
- package/dist/access-drizzle.cjs +1 -0
- package/dist/access-drizzle.d.cts +726 -0
- package/dist/access-drizzle.d.ts +726 -0
- package/dist/access-drizzle.js +1 -0
- package/dist/access-hono.cjs +3 -0
- package/dist/access-hono.d.cts +108 -0
- package/dist/access-hono.d.ts +108 -0
- package/dist/access-hono.js +3 -0
- package/dist/access-implications.cjs +1 -0
- package/dist/access-implications.d.cts +25 -0
- package/dist/access-implications.d.ts +25 -0
- package/dist/access-implications.js +1 -0
- package/dist/access.cjs +26 -0
- package/dist/access.d.cts +884 -0
- package/dist/access.d.ts +884 -0
- package/dist/access.js +26 -0
- package/dist/catalog-CSSbrfEf.d.cts +361 -0
- package/dist/catalog-CSSbrfEf.d.ts +361 -0
- package/dist/chunk-3KUOBGJN.js +19 -0
- package/dist/chunk-3WA3M347.cjs +1 -0
- package/dist/chunk-5CU33JVY.js +1 -0
- package/dist/chunk-7K37PVGV.js +5 -0
- package/dist/chunk-7XHF3YSK.js +1 -0
- package/dist/chunk-AJY7ZHXK.cjs +3 -0
- package/dist/chunk-BN6TGHSB.cjs +1 -0
- package/dist/chunk-BTHXQWHT.js +1 -0
- package/dist/chunk-C2OCOPZQ.cjs +1 -0
- package/dist/chunk-IMAVYQ4U.cjs +4 -0
- package/dist/chunk-OP64Y4IN.cjs +1 -0
- package/dist/chunk-QKSPPMVN.js +1 -0
- package/dist/chunk-ROFUQLOZ.cjs +1 -0
- package/dist/chunk-SNUCO2MK.js +1 -0
- package/dist/chunk-THMWRUAT.cjs +19 -0
- package/dist/chunk-VMCGDDGP.js +3 -0
- package/dist/chunk-W7C357A6.cjs +5 -0
- package/dist/chunk-WLTASJF6.js +4 -0
- package/dist/egress.cjs +7 -0
- package/dist/egress.d.cts +9 -0
- package/dist/egress.d.ts +9 -0
- package/dist/egress.js +7 -0
- package/dist/frontend.cjs +2 -0
- package/dist/frontend.d.cts +353 -0
- package/dist/frontend.d.ts +353 -0
- package/dist/frontend.js +2 -0
- package/dist/gateway-signature.cjs +1 -0
- package/dist/gateway-signature.d.cts +112 -0
- package/dist/gateway-signature.d.ts +112 -0
- package/dist/gateway-signature.js +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +528 -0
- package/dist/index.d.ts +528 -0
- package/dist/index.js +1 -0
- package/dist/manifest-types-DQARCQJt.d.cts +627 -0
- package/dist/manifest-types-DQARCQJt.d.ts +627 -0
- package/dist/manifest.cjs +1 -0
- package/dist/manifest.d.cts +2824 -0
- package/dist/manifest.d.ts +2824 -0
- package/dist/manifest.js +1 -0
- package/dist/mcp-identity.cjs +2 -0
- package/dist/mcp-identity.d.cts +106 -0
- package/dist/mcp-identity.d.ts +106 -0
- package/dist/mcp-identity.js +2 -0
- package/dist/pack.cjs +1 -0
- package/dist/pack.d.cts +169 -0
- package/dist/pack.d.ts +169 -0
- package/dist/pack.js +1 -0
- package/dist/publish.cjs +1 -0
- package/dist/publish.d.cts +96 -0
- package/dist/publish.d.ts +96 -0
- package/dist/publish.js +1 -0
- package/dist/scaffold.cjs +523 -0
- package/dist/scaffold.d.cts +182 -0
- package/dist/scaffold.d.ts +182 -0
- package/dist/scaffold.js +523 -0
- package/dist/server-runtime.cjs +1 -0
- package/dist/server-runtime.d.cts +63 -0
- package/dist/server-runtime.d.ts +63 -0
- package/dist/server-runtime.js +1 -0
- package/dist/server.cjs +1 -0
- package/dist/server.d.cts +1808 -0
- package/dist/server.d.ts +1808 -0
- package/dist/server.js +1 -0
- package/dist/testing.cjs +2 -0
- package/dist/testing.d.cts +92 -0
- package/dist/testing.d.ts +92 -0
- package/dist/testing.js +2 -0
- package/dist/vite.cjs +1 -0
- package/dist/vite.d.cts +133 -0
- package/dist/vite.d.ts +133 -0
- package/dist/vite.js +1 -0
- package/package.json +160 -4
- package/sql/access.sql +73 -0
package/README.md
CHANGED
|
@@ -1,3 +1,847 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tego/plugin-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Everything needed to build, package and test a Tego plugin.
|
|
4
|
+
|
|
5
|
+
Before this existed the only reference was `plugins/tego-crm`'s source, which
|
|
6
|
+
meant every new plugin inherited that one's accidental decisions and each
|
|
7
|
+
correction cost twice as much as the last. Read this instead; open CRM only for
|
|
8
|
+
an example of a screen.
|
|
9
|
+
|
|
10
|
+
- [What a plugin is](#what-a-plugin-is)
|
|
11
|
+
- [Which part does what](#which-part-does-what)
|
|
12
|
+
- [Package layout](#package-layout)
|
|
13
|
+
- [The manifest](#the-manifest)
|
|
14
|
+
- [`hostApiRange`](#hostapirange)
|
|
15
|
+
- [Configuration](#configuration)
|
|
16
|
+
- [Building and packaging](#building-and-packaging)
|
|
17
|
+
- [The local loop](#the-local-loop)
|
|
18
|
+
- [Testing](#testing)
|
|
19
|
+
- [Developing outside this repository](#developing-outside-this-repository)
|
|
20
|
+
- [Upload, install, run](#upload-install-run)
|
|
21
|
+
|
|
22
|
+
## What a plugin is
|
|
23
|
+
|
|
24
|
+
One archive with up to three parts, described by a single `tego.plugin.json` at
|
|
25
|
+
its root. At least one part is required.
|
|
26
|
+
|
|
27
|
+
| Part | Runs | Written against |
|
|
28
|
+
| ---------- | ----------------------------------- | ------------------------------------ |
|
|
29
|
+
| `frontend` | In the shell, as a SystemJS module | `@tego/plugin-sdk/frontend` |
|
|
30
|
+
| `backend` | As a container the platform manages | `@tego/plugin-sdk/server-runtime` |
|
|
31
|
+
| `claw` | Inside an avatar's claw container | OpenClaw's own plugin SDK, unchanged |
|
|
32
|
+
|
|
33
|
+
Start one with the scaffold rather than by copying:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pnpm plugin:new
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It asks for an id, display names, a scope and which parts you want, then writes
|
|
40
|
+
a package that passes upload validation as-is — manifest, ten locale bundles,
|
|
41
|
+
and one working example per part.
|
|
42
|
+
|
|
43
|
+
## Which part does what
|
|
44
|
+
|
|
45
|
+
**The frontend part opens a full working page.** A record list, a detail view,
|
|
46
|
+
a dashboard: things a person navigates to from the sidebar and works in. It
|
|
47
|
+
owns every route under `/p/<pluginId>`.
|
|
48
|
+
|
|
49
|
+
The sidebar gives you **one row**, named by your `displayName`, sitting in the
|
|
50
|
+
扩展 flyout. It opens `/p/<pluginId>` and that is the last say the shell has:
|
|
51
|
+
which page a user lands on, and how they move between your pages, is yours to
|
|
52
|
+
render — a tab bar, a nav rail, whatever the work needs. The shell cannot
|
|
53
|
+
publish your pages as rows of its own, because how many there are is decided by
|
|
54
|
+
whoever installs plugins, and three plugins of four pages each would push the
|
|
55
|
+
built-in sections off the screen.
|
|
56
|
+
|
|
57
|
+
**Anything inside a conversation goes through the claw part**, driven by the
|
|
58
|
+
avatar: a skill tells the agent when to act, and the agent draws cards with the
|
|
59
|
+
platform's a2ui tools. Do not try to render into the chat from the frontend
|
|
60
|
+
part — there is no seam for it, and the one that exists is claw's.
|
|
61
|
+
|
|
62
|
+
**The backend part is the plugin's own service and its own data.** It gets a
|
|
63
|
+
dedicated Postgres schema, a role scoped to it, and object storage under its own
|
|
64
|
+
prefix. It is reachable only through the platform: the gateway authenticates the
|
|
65
|
+
caller, checks the tenant and the permission, and forwards. No published port,
|
|
66
|
+
no ingress route.
|
|
67
|
+
|
|
68
|
+
A plugin does not need all three. A claw-only plugin is a normal thing to ship.
|
|
69
|
+
|
|
70
|
+
### The identity your backend receives is verified for you
|
|
71
|
+
|
|
72
|
+
Your `fetch(request, ctx, host)` is called with a `ctx` naming the user, their
|
|
73
|
+
tenant, their roles and — when a claw container is calling — the avatar. You can
|
|
74
|
+
authorize against all of it. `serveBackend` has already checked that the
|
|
75
|
+
platform is what wrote it: the gateway signs the identity per request with a key
|
|
76
|
+
derived for your install, and anything that does not verify gets a 401 without
|
|
77
|
+
reaching your code.
|
|
78
|
+
|
|
79
|
+
So do not accept identity from anywhere else. A `userId` in a query parameter,
|
|
80
|
+
a body field or a header of your own is a caller-supplied string, and the whole
|
|
81
|
+
point of `ctx` is that it is not. There is also no token to parse — the caller's
|
|
82
|
+
credentials stop at the platform, which is what keeps a plugin from being a
|
|
83
|
+
place the user's entire account can be driven from.
|
|
84
|
+
|
|
85
|
+
### Your frontend needs its own error boundary
|
|
86
|
+
|
|
87
|
+
The shell mounts you into a plain DOM node and wraps that node in a boundary,
|
|
88
|
+
so a throw from `mount` itself, or from the shell's own work around it, shows
|
|
89
|
+
an error page with the sidebar intact rather than taking the app down.
|
|
90
|
+
|
|
91
|
+
That boundary cannot see inside you. You render into your own React root, and
|
|
92
|
+
React contains a root's render errors within that root — the shell's boundary
|
|
93
|
+
is never on the path. Without one of your own, a component that throws on the
|
|
94
|
+
third tab leaves a blank rectangle and a console entry, which reads as the
|
|
95
|
+
platform having lost the page.
|
|
96
|
+
|
|
97
|
+
## Package layout
|
|
98
|
+
|
|
99
|
+
```plaintext
|
|
100
|
+
my-plugin/
|
|
101
|
+
├── tego.plugin.json # the manifest — the only required file
|
|
102
|
+
├── frontend/
|
|
103
|
+
│ ├── index.js # SystemJS bundle, built by the SDK's vite preset
|
|
104
|
+
│ └── i18n/<locale>.json # all ten locales, see below
|
|
105
|
+
├── backend/
|
|
106
|
+
│ ├── index.js # one call to serveBackend()
|
|
107
|
+
│ └── migrations/*.sql # append-only, applied by the host
|
|
108
|
+
└── claw/
|
|
109
|
+
├── openclaw.plugin.json # an intact OpenClaw manifest
|
|
110
|
+
├── index.js
|
|
111
|
+
└── skills/
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Every path above is a default you can override in the manifest, but there is
|
|
115
|
+
rarely a reason to. `tego-plugin-pack` produces exactly this from your sources.
|
|
116
|
+
|
|
117
|
+
The ten locales are not negotiable for a part that ships UI. The platform
|
|
118
|
+
serves `zh-CN zh-TW en ja ko de es fr it pt`; shipping two of them means a
|
|
119
|
+
Japanese operator opens your plugin and finds the one screen in the product
|
|
120
|
+
that is untranslated, which reads as the platform being half-finished. Upload
|
|
121
|
+
refuses an incomplete set, where you can still act on it — a runtime fallback
|
|
122
|
+
would hide the gap forever.
|
|
123
|
+
|
|
124
|
+
## The manifest
|
|
125
|
+
|
|
126
|
+
```jsonc
|
|
127
|
+
{
|
|
128
|
+
"manifestVersion": 1,
|
|
129
|
+
"id": "acme-crm",
|
|
130
|
+
"version": "1.0.0",
|
|
131
|
+
"displayName": { "zh-CN": "客户关系", "en": "CRM" },
|
|
132
|
+
"scope": "avatar",
|
|
133
|
+
"hostApiRange": "^1.3.0",
|
|
134
|
+
"declares": { "database": true, "storage": true, "events": true },
|
|
135
|
+
"parts": {
|
|
136
|
+
"frontend": {
|
|
137
|
+
"icon": "handshake",
|
|
138
|
+
"nav": [
|
|
139
|
+
{
|
|
140
|
+
"key": "companies",
|
|
141
|
+
"labelKey": "nav.companies",
|
|
142
|
+
"icon": "building",
|
|
143
|
+
"path": "/companies",
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
"backend": {},
|
|
148
|
+
"claw": {},
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**`scope`** decides who the plugin belongs to, and it is declared rather than
|
|
154
|
+
inferred.
|
|
155
|
+
|
|
156
|
+
- `avatar` — a capability of a digital avatar. Install picks which avatars get
|
|
157
|
+
it; its pages appear while one of those is the active avatar. A plugin with a
|
|
158
|
+
claw part is necessarily this, since a claw extension loads inside one
|
|
159
|
+
avatar's container.
|
|
160
|
+
- `tenant` — a workspace application with no per-avatar meaning, open to
|
|
161
|
+
everyone in the tenant.
|
|
162
|
+
|
|
163
|
+
**`declares`** is what the install screen tells the operator: a dedicated
|
|
164
|
+
schema, object storage, `plugin.<id>.*` events. These are **disclosures, not
|
|
165
|
+
grants**. Plugins are first-party and the frontend runs with the shell's own
|
|
166
|
+
privileges, so calling them permissions would promise an isolation boundary
|
|
167
|
+
that is not there. `database` is the one that also changes behaviour, because
|
|
168
|
+
provisioning a schema is something the host does or does not do.
|
|
169
|
+
|
|
170
|
+
**`nav` declares your pages, not sidebar rows.** The sidebar shows your plugin
|
|
171
|
+
as one row that opens it at its root, and you route between your pages
|
|
172
|
+
yourself. Leave `nav` empty and your frontend gets neither a row nor a page.
|
|
173
|
+
|
|
174
|
+
**Access follows the avatars, not roles.** The host writes no role menu or
|
|
175
|
+
permission for a plugin. An avatar-scoped plugin may be used by whoever may use
|
|
176
|
+
an avatar it is installed on — an administrator, the avatar's owner, or someone
|
|
177
|
+
granted `use` or above; view-only access does not count. A tenant-scoped plugin
|
|
178
|
+
is open to the whole tenant. Access is all-or-nothing, so `ctx.can(key)` only
|
|
179
|
+
says whether `key` is one of your nav entries; per-page and per-record rules
|
|
180
|
+
belong in your access catalog (`@tego/plugin-sdk/access`).
|
|
181
|
+
|
|
182
|
+
**`frontend.icon` is your sidebar icon**; the per-entry `icon` and `order` in
|
|
183
|
+
`nav` are accepted for compatibility and not read. Icons come from a closed
|
|
184
|
+
list (`PLUGIN_NAV_ICONS`): an arbitrary `lucide-react` name can only be
|
|
185
|
+
resolved dynamically, which pulls the whole icon set into the shell's eager
|
|
186
|
+
bundle to decorate rows most tenants do not have. Declare nothing and you get
|
|
187
|
+
the generic plugin glyph.
|
|
188
|
+
|
|
189
|
+
### Digital employee templates and work delegation
|
|
190
|
+
|
|
191
|
+
An avatar plugin may ship template assets in its own package:
|
|
192
|
+
|
|
193
|
+
```jsonc
|
|
194
|
+
"agentTemplates": [{
|
|
195
|
+
"key": "safety-employee",
|
|
196
|
+
"entry": "templates/safety-employee/template.json",
|
|
197
|
+
"icon": "templates/safety-employee/avatar.png"
|
|
198
|
+
}]
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Author the template as a directory beside its entry: `template.config.json`
|
|
202
|
+
(everything except workspace documents), `workspace/*.md` for the main agent's
|
|
203
|
+
root files, other files under `workspace/` shipped at their relative path, and
|
|
204
|
+
— when the config lists `agents`, main first — one `experts/<id>/` per expert.
|
|
205
|
+
`tego-plugin-pack` rebuilds `template.json` from these before packing (commit
|
|
206
|
+
the result); `tego-plugin-pack templates` rebuilds without packing. An entry
|
|
207
|
+
with no `template.config.json` beside it is shipped as written.
|
|
208
|
+
|
|
209
|
+
`tego-plugin-pack` copies only the declared files. Upload validates the template
|
|
210
|
+
against the platform template schema. Approving a release publishes its
|
|
211
|
+
templates for that tenant; retiring, revoking or archiving the last approved
|
|
212
|
+
release hides them, and the tenant's catalog lists them only while the plugin
|
|
213
|
+
is installed and enabled. The template row belongs to the release: admins can
|
|
214
|
+
change its price, nothing else. The host adds the owning plugin to
|
|
215
|
+
`strictRequiredPlugins`, so creation fails if it cannot bind the plugin to the
|
|
216
|
+
new employee. Template content and portrait remain in the plugin package.
|
|
217
|
+
|
|
218
|
+
Plugins using the host's work delegation API must declare a policy:
|
|
219
|
+
|
|
220
|
+
```jsonc
|
|
221
|
+
"workDelegation": { "allowedAvatarRoles": ["sales-assistant"] }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The host checks the installed release's policy when listing candidates,
|
|
225
|
+
choosing a workspace default, enabling delegation and resolving an existing
|
|
226
|
+
grant. Omission denies delegation; `"*"` permits any employee role. Revoking
|
|
227
|
+
an existing grant remains possible when the employee is no longer eligible.
|
|
228
|
+
|
|
229
|
+
The host owns the consent flow; plugins do not build grant or revoke buttons.
|
|
230
|
+
A person grants per employee in a host dialog that states what the employee
|
|
231
|
+
may do and the backend's declared `egress`. The dialog opens on its own the
|
|
232
|
+
first time the person enters the plugin with an employee they have not
|
|
233
|
+
granted; declining keeps the plugin usable and the host does not prompt again
|
|
234
|
+
for that employee. Describe the work in the dialog with optional fields:
|
|
235
|
+
|
|
236
|
+
```jsonc
|
|
237
|
+
"workDelegation": {
|
|
238
|
+
"allowedAvatarRoles": ["sales-assistant"],
|
|
239
|
+
"summary": { "zh-CN": "员工会读取你可见的客户…", "en": "Reads the companies you can see…" },
|
|
240
|
+
"privacyPolicyUrl": "https://example.com/privacy",
|
|
241
|
+
"conversationCapture": true
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`summary` is at most 120 characters per locale. `privacyPolicyUrl` must be
|
|
246
|
+
https. `conversationCapture: true` adds an unticked option to record the
|
|
247
|
+
person's new conversations with the employee, for plugins that read
|
|
248
|
+
`host.sources`. These fields need `hostApiRange` `^1.33.0` or later.
|
|
249
|
+
|
|
250
|
+
From 1.33 the frontend reads the state from `ctx.delegation` and calls
|
|
251
|
+
`ctx.delegation.open()` wherever work needs the grant; the promise resolves
|
|
252
|
+
with the state once the dialog closes:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
const state = ctx.delegation?.current(); // status: granted | not_granted | not_ready | ineligible | no_employee | loading
|
|
256
|
+
if (state?.status !== 'granted') {
|
|
257
|
+
const next = await ctx.delegation?.open();
|
|
258
|
+
if (next?.status !== 'granted') return;
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`ctx.delegation` is present only when the manifest declares `workDelegation`
|
|
263
|
+
and `hostApiRange` starts at 1.33 or later. Plugins without a frontend need
|
|
264
|
+
nothing more: the usage guide entry below opens the same dialog. Delegation
|
|
265
|
+
is judged by your backend through the signed `delegation` on machine
|
|
266
|
+
requests, so the manifest is rejected when `workDelegation` has no backend part. Grants
|
|
267
|
+
and revocations are recorded in the governance audit with the release
|
|
268
|
+
version; a plugin upgrade does not ask people to grant again.
|
|
269
|
+
|
|
270
|
+
The host also lists delegation in the employee's usage guide: for every person
|
|
271
|
+
using an eligible employee it adds the setup item
|
|
272
|
+
`plugin:<pluginId>:work_delegation`, `ready` once that person has delegated.
|
|
273
|
+
Its button opens the host dialog. `settingsRoute` is deprecated: the host no
|
|
274
|
+
longer links the item to a plugin page. Template quick prompts can name the
|
|
275
|
+
item in `requires`. Do not report delegation from your own readiness route.
|
|
276
|
+
|
|
277
|
+
Plugins that need people to finish something before an employee can work for
|
|
278
|
+
them report it per person:
|
|
279
|
+
|
|
280
|
+
```jsonc
|
|
281
|
+
"agentReadiness": { "path": "readiness" }
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`path` is a backend route relative to the plugin root, so the manifest needs a
|
|
285
|
+
backend part. Declaring `agentReadiness` or `workDelegation.settingsRoute`
|
|
286
|
+
requires `hostApiRange` `^1.30.0` or later.
|
|
287
|
+
|
|
288
|
+
The host calls the route as the viewing person, with `avatarId` in the query,
|
|
289
|
+
and expects `{ "items": [...] }` (optionally wrapped in `data`). Each item has
|
|
290
|
+
an `id` unique within the plugin, `title`, `status` (`ready`, `action_needed`,
|
|
291
|
+
`not_applicable` hides the item and the quick prompts requiring it, `unknown`),
|
|
292
|
+
`owner` (`self` or `other`), and optionally `why`, `ownerHint`, `optional` and
|
|
293
|
+
`action: { label, route }`. Text fields accept `LocalizedText`; the host picks
|
|
294
|
+
the viewer's locale, then `en`. `action.route` is a path inside your plugin.
|
|
295
|
+
A response that fails validation, times out, repeats an id or uses
|
|
296
|
+
`work_delegation` is shown as a single `unknown` item.
|
|
297
|
+
|
|
298
|
+
Plugins that bind a business record to a conversation declare their read-only
|
|
299
|
+
routes instead of adding entity names or paths to the host:
|
|
300
|
+
|
|
301
|
+
```jsonc
|
|
302
|
+
"conversationRecordRoutes": [
|
|
303
|
+
{ "entity": "contact", "path": "contacts", "contextQuery": "context=pipeline" }
|
|
304
|
+
]
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The host calls the declared route with the URL-encoded record ID and the
|
|
308
|
+
verified user context. The plugin remains responsible for record permission
|
|
309
|
+
checks and the returned record and context projection.
|
|
310
|
+
|
|
311
|
+
## `hostApiRange`
|
|
312
|
+
|
|
313
|
+
A caret range over the shared-module surface the shell hands your frontend:
|
|
314
|
+
React, `@tego/ui`, `react-i18next`, `lucide-react`,
|
|
315
|
+
`@tego/chat/plugin-surface`. The current version is exported as
|
|
316
|
+
`HOST_API_VERSION`.
|
|
317
|
+
|
|
318
|
+
Declare the lowest version whose features you actually use, not the newest one
|
|
319
|
+
available. `^1.0.0` keeps loading on every 1.x shell; `^1.3.0` says you need
|
|
320
|
+
`PluginHostContext.events` and will be refused by a shell that predates it. A
|
|
321
|
+
major bump refuses every plugin declaring an older range, which is the point —
|
|
322
|
+
the alternative is failing inside a hook with a message about neither.
|
|
323
|
+
|
|
324
|
+
Required once you ship a frontend or backend part. A claw-only plugin is
|
|
325
|
+
governed by claw's own `minHostVersion` instead.
|
|
326
|
+
|
|
327
|
+
## Roles and permissions inside your plugin (Host API 1.22)
|
|
328
|
+
|
|
329
|
+
`nav` decides who may open your plugin; what they may do once inside is yours,
|
|
330
|
+
and `@tego/plugin-sdk/access` is how you answer it. Accounts and host roles stay
|
|
331
|
+
the host's. Your roles, their grants and who holds them live in your own schema,
|
|
332
|
+
and the tenant's administrators edit them in a screen you embed. The model and
|
|
333
|
+
the reasons behind it are in `docs/design/plugin-access.md`.
|
|
334
|
+
|
|
335
|
+
Declare a catalog, validated by `tego-plugin-pack` against
|
|
336
|
+
`@tego/plugin-sdk/access-catalog.schema.json`:
|
|
337
|
+
|
|
338
|
+
```jsonc
|
|
339
|
+
// tego.plugin.json
|
|
340
|
+
{ "hostApiRange": "^1.22.0", "access": { "catalog": "shared/access.json" } }
|
|
341
|
+
|
|
342
|
+
// shared/access.json
|
|
343
|
+
{
|
|
344
|
+
"version": 1,
|
|
345
|
+
"groups": [{ "key": "records", "labelKey": "access.group.records" }],
|
|
346
|
+
"scopes": { "own": { "labelKey": "access.scope.own" }, "all": { "labelKey": "access.scope.all" } },
|
|
347
|
+
"permissions": {
|
|
348
|
+
"record.view": { "group": "records", "labelKey": "access.record.view", "scopes": ["own", "all"],
|
|
349
|
+
"agent": { "requires": ["records.read"] } },
|
|
350
|
+
"record.delete": { "group": "records", "labelKey": "access.record.delete", "agent": { "allowed": false } }
|
|
351
|
+
},
|
|
352
|
+
"roles": {
|
|
353
|
+
"admin": { "nameKey": "access.role.admin", "grants": { "access.manage": true, "record.view": "all", "record.delete": true } },
|
|
354
|
+
"member": { "nameKey": "access.role.member", "grants": { "record.view": "own" } }
|
|
355
|
+
},
|
|
356
|
+
"hostRoleBindings": { "admin": ["admin"], "super_admin": ["admin"] },
|
|
357
|
+
"defaults": { "unassigned": "member" },
|
|
358
|
+
"assignment": { "principals": ["user", "unit"] }
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The build refuses a catalog that cannot work:
|
|
363
|
+
|
|
364
|
+
- a reference to an undeclared permission or scope;
|
|
365
|
+
- a menu key that is not in `nav`;
|
|
366
|
+
- `unit` principals without `directory.read`;
|
|
367
|
+
- a host `admin` who could not reach the access settings, which is the one
|
|
368
|
+
way a tenant locks itself out.
|
|
369
|
+
|
|
370
|
+
On the backend, one engine per plugin and one session per request:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
import { createAccess, accessCatalogFromJson } from '@tego/plugin-sdk/access';
|
|
374
|
+
import { accessHono, assertRouteCoverage } from '@tego/plugin-sdk/access/hono';
|
|
375
|
+
|
|
376
|
+
export const access = createAccess({
|
|
377
|
+
catalog: accessCatalogFromJson(catalogJson),
|
|
378
|
+
});
|
|
379
|
+
const { policy, routes } = accessHono({ session: c => c.env.access }); // access.forRequest(ctx, host)
|
|
380
|
+
|
|
381
|
+
app.get('/records', policy.permission('record.view'), listRecords);
|
|
382
|
+
app.delete(
|
|
383
|
+
'/records/:id',
|
|
384
|
+
policy.resource('record.delete', loadRecord, { conceal: true }),
|
|
385
|
+
deleteRecord
|
|
386
|
+
);
|
|
387
|
+
app.route('/', routes()); // /access/me, /access/roles, /access/assignments, …
|
|
388
|
+
|
|
389
|
+
// in start(host): bring the built-in roles in line with the catalog
|
|
390
|
+
await access.reconcile(host);
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
- **Every route carries exactly one policy.** Test it with
|
|
394
|
+
`assertRouteCoverage(app, { publicRoutes })`. An unmarked route fails the
|
|
395
|
+
test rather than defaulting to open.
|
|
396
|
+
- **Lists go through the scope.** `session.filter('record.view', { alias: 'r' })`
|
|
397
|
+
returns the SQL condition for a list query. It comes from the same evaluator
|
|
398
|
+
that judges a single record, so the list and the detail page cannot disagree.
|
|
399
|
+
- **`authorizeStorage` goes through the same engine:**
|
|
400
|
+
`access.forSubject(await access.subjectFor(userId, host), host)`.
|
|
401
|
+
- **Background jobs never act as a user.** They are trusted system code; work
|
|
402
|
+
done for a person arrives through claw's `fetch`, with a delegation.
|
|
403
|
+
- **The tables go in your migrations.** Re-export them from
|
|
404
|
+
`@tego/plugin-sdk/access/drizzle` and run `db:generate`; a Python backend uses
|
|
405
|
+
`@tego/plugin-sdk/access.sql`. Every table carries `tenant_id`, which
|
|
406
|
+
`host.tenantId` supplies.
|
|
407
|
+
|
|
408
|
+
On the frontend, `@tego/plugin-access-ui` is bundled into your plugin, not
|
|
409
|
+
shared by the shell:
|
|
410
|
+
|
|
411
|
+
- mount `<AccessProvider api={ctx.api}>` under your own `QueryClientProvider`;
|
|
412
|
+
- gate the UI with `useAccess()`, `<Can>` and `filterNav`;
|
|
413
|
+
- embed `<AccessSettings />` in your settings;
|
|
414
|
+
- add the package to your Tailwind `content`.
|
|
415
|
+
|
|
416
|
+
`tego-plugin-pack` merges its strings into your ten locales under `tegoAccess`.
|
|
417
|
+
What the frontend hides is presentation only: the backend is the boundary.
|
|
418
|
+
|
|
419
|
+
Python backends get the same model, tables and `/access` contract from
|
|
420
|
+
`tego_plugin_sdk.access`, with the FastAPI adapter in
|
|
421
|
+
`tego_plugin_sdk.access.fastapi`: `AccessFastApi`, and `assert_route_coverage`.
|
|
422
|
+
|
|
423
|
+
## Configuration
|
|
424
|
+
|
|
425
|
+
Three sections, declared and stored separately: `frontend`, `backend`, `claw`.
|
|
426
|
+
They are never merged. Which section a value is in decides who ever sees it, so
|
|
427
|
+
the separation is the security property rather than a filing convention.
|
|
428
|
+
|
|
429
|
+
```mermaid
|
|
430
|
+
flowchart LR
|
|
431
|
+
fe["parts.frontend.config"] --> store["stored per install,<br/>three rows"]
|
|
432
|
+
be["parts.backend.config"] --> store
|
|
433
|
+
claw["configSchema in<br/>claw/openclaw.plugin.json"] --> store
|
|
434
|
+
store -->|"with discovery"| feOut["the browser"]
|
|
435
|
+
store -->|"host API, on startup"| beOut["the backend container"]
|
|
436
|
+
store -->|"merged with per-avatar overrides"| clawOut["the avatar's openclaw.json"]
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Declare the frontend and backend sections in `tego.plugin.json`:
|
|
440
|
+
|
|
441
|
+
```jsonc
|
|
442
|
+
"backend": {
|
|
443
|
+
"config": {
|
|
444
|
+
"schema": {
|
|
445
|
+
"type": "object",
|
|
446
|
+
"properties": {
|
|
447
|
+
"apiBaseUrl": { "type": "string", "format": "uri" },
|
|
448
|
+
"apiKey": { "type": "string", "minLength": 8 }
|
|
449
|
+
},
|
|
450
|
+
"required": ["apiBaseUrl", "apiKey"],
|
|
451
|
+
"additionalProperties": false
|
|
452
|
+
},
|
|
453
|
+
"uiHints": {
|
|
454
|
+
"apiKey": {
|
|
455
|
+
"label": { "zh-CN": "接口密钥", "en": "API key" },
|
|
456
|
+
"sensitive": true
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Do not declare the claw section here.** `claw/openclaw.plugin.json` already
|
|
464
|
+
carries a `configSchema` that claw itself validates against; a second
|
|
465
|
+
declaration would be a second truth about the same fields.
|
|
466
|
+
|
|
467
|
+
### The allowed schema is narrow
|
|
468
|
+
|
|
469
|
+
A flat object of scalars, string enums and string arrays. No nesting, no
|
|
470
|
+
`$ref`, no unions. Two reasons, and the second is load-bearing:
|
|
471
|
+
|
|
472
|
+
- The form renders without you previewing it. Anything the renderer would fall
|
|
473
|
+
back to a JSON textarea for is a field the operator fills by typing JSON,
|
|
474
|
+
which is not a form.
|
|
475
|
+
- Per-avatar claw overrides merge at top-level key granularity, because
|
|
476
|
+
deep-merging objects and arrays has no single obvious semantics. A nested
|
|
477
|
+
object would be replaced wholesale by an override, which is not what its
|
|
478
|
+
shape suggests.
|
|
479
|
+
|
|
480
|
+
### Secrets
|
|
481
|
+
|
|
482
|
+
Mark a field `sensitive` and it is stored encrypted, never returned in
|
|
483
|
+
plaintext, and never placed in the container's environment. Reads substitute
|
|
484
|
+
`__OPENCLAW_REDACTED__`; writing that value back means "keep what is stored", so
|
|
485
|
+
saving a form does not require retyping a key nobody was shown.
|
|
486
|
+
|
|
487
|
+
`sensitive` in the **frontend** section is a validation error, not a stripped
|
|
488
|
+
flag: everything that section contains is delivered to the browser, so a secret
|
|
489
|
+
there is public by construction.
|
|
490
|
+
|
|
491
|
+
### How each part reads its own
|
|
492
|
+
|
|
493
|
+
Frontend, already resolved when the surface mounts:
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
export default definePluginFrontend({
|
|
497
|
+
mount(el, ctx) {
|
|
498
|
+
const endpoint = ctx.config['apiBaseUrl'];
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Backend, fetched over the host API before the first request is accepted:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
const { apiKey } = host.config.current();
|
|
507
|
+
await host.config.refresh(); // for a rotated credential, without a restart
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Environment variables are deliberately not the channel. A secret in the
|
|
511
|
+
environment appears in `docker inspect`, in the orchestrator's API and in crash
|
|
512
|
+
dumps, and it is fixed at container creation — rotating a key would mean
|
|
513
|
+
recreating the container.
|
|
514
|
+
|
|
515
|
+
Claw reads its section from the avatar's `openclaw.json`, where the platform
|
|
516
|
+
projects the tenant default with that avatar's overrides applied.
|
|
517
|
+
|
|
518
|
+
### Required and unset
|
|
519
|
+
|
|
520
|
+
A **backend** field that is required and unfilled leaves the install in
|
|
521
|
+
`awaiting_config`: installed, no container started, and calls answered with an
|
|
522
|
+
explanation instead of a 502. Filling it in starts the backend. Only the backend
|
|
523
|
+
section can hold anything back — a missing frontend setting is a page that
|
|
524
|
+
renders wrong, and a missing claw setting is claw's business.
|
|
525
|
+
|
|
526
|
+
An upgrade whose new schema rejects the stored values puts the backend back into
|
|
527
|
+
`awaiting_config` rather than starting it on a value it will choke on.
|
|
528
|
+
|
|
529
|
+
## Building and packaging
|
|
530
|
+
|
|
531
|
+
```bash
|
|
532
|
+
pnpm exec tego-plugin-pack # from the package root
|
|
533
|
+
pnpm exec tego-plugin-pack --watch # frontend rebuild loop, see below
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
The packer owns the archive layout because that layout is the platform's
|
|
537
|
+
contract: the server reads `tego.plugin.json`, resolves the part defaults, and
|
|
538
|
+
expects the bundle, the migrations and the claw subtree at exactly those paths.
|
|
539
|
+
It builds the frontend with Vite through the SDK's preset, bundles the backend
|
|
540
|
+
and claw entries with esbuild, and writes a deterministic ZIP.
|
|
541
|
+
|
|
542
|
+
Your `vite.config.ts` is one call:
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
import { tegoPluginFrontend } from '@tego/plugin-sdk/vite';
|
|
546
|
+
|
|
547
|
+
export default tegoPluginFrontend({ entry: 'src/index.tsx' });
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
The preset is not a convenience wrapper. The shell can only load a SystemJS
|
|
551
|
+
module whose shared imports were rewritten to the exact ids it registered, and
|
|
552
|
+
both of those are one-line mistakes that build cleanly and fail at load with a
|
|
553
|
+
message about an unknown scheme.
|
|
554
|
+
|
|
555
|
+
### Styling with Tailwind
|
|
556
|
+
|
|
557
|
+
A plugin that compiles its own utilities passes `tegoPluginTailwind` to
|
|
558
|
+
`tailwindcss` (the plugin's own dependency):
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
import { resolve } from 'node:path';
|
|
562
|
+
|
|
563
|
+
import { tegoPluginFrontend, tegoPluginTailwind } from '@tego/plugin-sdk/vite';
|
|
564
|
+
import tailwindcss from 'tailwindcss';
|
|
565
|
+
|
|
566
|
+
const config = tegoPluginFrontend({ entry: 'frontend/src/index.tsx', pluginId: 'acme-crm' });
|
|
567
|
+
config.css = {
|
|
568
|
+
postcss: {
|
|
569
|
+
plugins: [
|
|
570
|
+
tailwindcss(
|
|
571
|
+
tegoPluginTailwind({
|
|
572
|
+
pluginId: 'acme-crm',
|
|
573
|
+
root: import.meta.dirname,
|
|
574
|
+
content: [resolve(import.meta.dirname, 'src/**/*.{ts,tsx}')],
|
|
575
|
+
})
|
|
576
|
+
),
|
|
577
|
+
],
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
export default config;
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
It takes the design tokens from `@tego/ui`'s preset (the one the shell compiles
|
|
584
|
+
with), leaves the reset to the shell, scopes every utility under `.<pluginId>`,
|
|
585
|
+
and compiles the classes of `@tego/ui` and `@tego/plugin-access-ui` components
|
|
586
|
+
the bundle uses. Packages resolve from `root`, so the same call works in this
|
|
587
|
+
repo and in a standalone one. Do not load a `tailwind.config.js` by relative
|
|
588
|
+
path: outside this repository there is nothing at that path.
|
|
589
|
+
|
|
590
|
+
For anything the conventions cannot infer, call `packPlugin` from a script:
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
import { packPlugin } from '@tego/plugin-sdk/pack';
|
|
594
|
+
|
|
595
|
+
await packPlugin({ root: process.cwd(), backend: { external: ['sharp'] } });
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
## The local loop
|
|
599
|
+
|
|
600
|
+
Without this, changing one line of frontend code costs a pack, an upload, an
|
|
601
|
+
install and a refresh.
|
|
602
|
+
|
|
603
|
+
```bash
|
|
604
|
+
pnpm exec tego-plugin-pack --watch # serves the bundle on :5180
|
|
605
|
+
VITE_TEGO_LOCAL_PLUGINS=acme-crm=http://localhost:5180/index.js pnpm -F @tego/ai-studio dev
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Saving a file rebuilds and reloads the plugin page. The release still supplies
|
|
609
|
+
everything else — nav entries, permissions, version — only the bundle's origin
|
|
610
|
+
changes, and the override is compiled out of a production build entirely.
|
|
611
|
+
|
|
612
|
+
There is **no module-level HMR**. State inside the page is lost on each rebuild.
|
|
613
|
+
Hot replacement across the SystemJS boundary means unmounting the plugin's React
|
|
614
|
+
root and re-registering shared modules, which is a lot of machinery for
|
|
615
|
+
something that would silently desync; a one-second reload is always right.
|
|
616
|
+
|
|
617
|
+
The backend runs on the host instead of in a container:
|
|
618
|
+
|
|
619
|
+
```bash
|
|
620
|
+
TEGO_PLUGIN_BACKEND_BASE_URL=http://localhost:18800 pnpm -F @tego/server dev
|
|
621
|
+
|
|
622
|
+
pnpm -F @tego/server exec tsx scripts/run-plugin-backend-locally.ts \
|
|
623
|
+
acme-crm "$PWD/plugins/acme-crm/build/acme-crm-1.0.0.zip"
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
The platform then adopts the process at that address rather than starting a
|
|
627
|
+
container it could not reach from the host. The port is fixed at 18800, and the
|
|
628
|
+
zip path has to be absolute — `pnpm exec` runs with `apps/server` as its working
|
|
629
|
+
directory. The plugin must already be installed; the script reads its schema
|
|
630
|
+
name and host API token from that install.
|
|
631
|
+
|
|
632
|
+
## Testing
|
|
633
|
+
|
|
634
|
+
The interesting code in a plugin backend is its SQL — multi-table writes in one
|
|
635
|
+
transaction, `FOR UPDATE SKIP LOCKED`, partial unique indexes — and none of that
|
|
636
|
+
means anything against a fake. `createTestPluginHost` stands up the real thing:
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
import { createTestPluginHost } from '@tego/plugin-sdk/testing';
|
|
640
|
+
|
|
641
|
+
const harness = await createTestPluginHost({
|
|
642
|
+
pluginId: 'acme-crm',
|
|
643
|
+
migrationsDir: new URL('../migrations', import.meta.url).pathname,
|
|
644
|
+
config: { apiBaseUrl: 'https://example.test' },
|
|
645
|
+
});
|
|
646
|
+
afterAll(() => harness.close());
|
|
647
|
+
beforeEach(() => harness.reset());
|
|
648
|
+
|
|
649
|
+
const response = await backend.fetch(request, userContext, harness.host);
|
|
650
|
+
expect(harness.publishedEvents).toEqual([...]);
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
A throwaway schema with your migrations applied, behind the same
|
|
654
|
+
`PluginScopedDb` the host hands a running backend — including real transactions
|
|
655
|
+
on one connection, which is the thing most easily got wrong: a body whose
|
|
656
|
+
statements land on pooled connections is not a transaction, and a double would
|
|
657
|
+
let that pass.
|
|
658
|
+
|
|
659
|
+
`harness.failPublish()` makes `events.publish` throw, which is the only way to
|
|
660
|
+
reach the case where a failed announcement undoes a committed write.
|
|
661
|
+
|
|
662
|
+
## Developing outside this repository
|
|
663
|
+
|
|
664
|
+
Three SDK packages are published to npm under `@zhama/tego-*` and installed
|
|
665
|
+
under their workspace names with npm aliases, so imports and `$schema` paths
|
|
666
|
+
read exactly as they do inside this repository:
|
|
667
|
+
|
|
668
|
+
```json
|
|
669
|
+
"devDependencies": {
|
|
670
|
+
"@tego/plugin-sdk": "npm:@zhama/tego-plugin-sdk@^1.35.0",
|
|
671
|
+
"@tego/ui": "npm:@zhama/tego-ui@^1.35.0",
|
|
672
|
+
"@tego/plugin-access-ui": "npm:@zhama/tego-plugin-access-ui@^1.35.0"
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
`@tego/plugin-access-ui` is only needed by a plugin that shows access
|
|
677
|
+
settings. The TypeScript presets and the lint config are not packages: the
|
|
678
|
+
scaffold writes them into the plugin (`tsconfig.base.json`,
|
|
679
|
+
`tsconfig.frontend.json`, `tsconfig.backend.json`, `eslint.config.js`). They
|
|
680
|
+
drive the editor, `tsgo` and ESLint only — the build is the SDK's — so change
|
|
681
|
+
them as you like.
|
|
682
|
+
|
|
683
|
+
The alias is required, not cosmetic: `@tego/ui` is a shared module id the shell
|
|
684
|
+
resolves your imports against, and the `@tego` scope on the public registry
|
|
685
|
+
belongs to another project. Never install a plain `@tego/*` package from npm.
|
|
686
|
+
|
|
687
|
+
Start from the scaffold instead of by hand. The SDK ships it as
|
|
688
|
+
`tego-plugin create`, so no checkout of this repository is needed: it writes
|
|
689
|
+
all of the above, `.prettierrc.json`, `.gitignore` and a `pnpm-workspace.yaml`
|
|
690
|
+
that allows esbuild's install script, into `<dir>/<id>/`. The answers come as
|
|
691
|
+
JSON on stdin (`scope` is `avatar` or `tenant`, default `avatar`), and
|
|
692
|
+
`hostApiRange` follows the SDK you run:
|
|
693
|
+
|
|
694
|
+
```bash
|
|
695
|
+
echo '{"id":"acme-notes","displayNameZh":"笔记","displayNameEn":"Notes",
|
|
696
|
+
"descriptionZh":"…","descriptionEn":"…","parts":["frontend","backend"]}' \
|
|
697
|
+
| npx -p @zhama/tego-plugin-sdk tego-plugin create --dir ~/code
|
|
698
|
+
cd ~/code/acme-notes && pnpm install
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
Inside this repository `pnpm plugin:scaffold --standalone ~/code` does the
|
|
702
|
+
same, and `pnpm plugin:new` writes the in-repo (`workspace:*`) variant; all
|
|
703
|
+
three use `@tego/plugin-sdk/scaffold`. Tego Studio creates plugins with
|
|
704
|
+
`tego-plugin create` too.
|
|
705
|
+
|
|
706
|
+
**Versions.** Every package is released at one version whose major.minor is
|
|
707
|
+
`HOST_API_VERSION`. A manifest declaring `"hostApiRange": "^1.35.0"` installs
|
|
708
|
+
`^1.35.0` of the SDK; raise both together when you start using a newer host
|
|
709
|
+
API.
|
|
710
|
+
|
|
711
|
+
**The chat surface.** `@tego/chat/plugin-surface` (an agent conversation
|
|
712
|
+
embedded in a plugin page) is available only to plugins built inside the Tego
|
|
713
|
+
repository. It is not published and the SDK carries no types for it.
|
|
714
|
+
|
|
715
|
+
**License.** The published packages are under the Tego Plugin SDK License
|
|
716
|
+
(`LICENSE` in each package): free to use for building, testing and
|
|
717
|
+
distributing plugins that run in Tego, not for other products, and not
|
|
718
|
+
redistributable on their own. Your plugin's own code stays yours.
|
|
719
|
+
|
|
720
|
+
**Toolchain.** Inside the repo the root `package.json` provides `tsgo`,
|
|
721
|
+
`eslint`, `prettier` and `typescript`. A standalone repository installs them
|
|
722
|
+
itself; the scaffold pins the same versions.
|
|
723
|
+
|
|
724
|
+
Releasing these packages is `pnpm sdk:publish` from the repo root (see
|
|
725
|
+
`scripts/sdk-release/`); `node scripts/sdk-release/smoke.mjs` proves a
|
|
726
|
+
standalone plugin installs, checks, tests and packs against the staged
|
|
727
|
+
tarballs with both npm and pnpm.
|
|
728
|
+
|
|
729
|
+
## Upload, install, run
|
|
730
|
+
|
|
731
|
+
1. **Pack.** `pnpm exec tego-plugin-pack` writes `build/<id>-<version>.zip`.
|
|
732
|
+
2. **Upload**, in the admin plugin screen. Everything that can be checked is
|
|
733
|
+
checked here, where you can still fix it: the manifest, the schema subset,
|
|
734
|
+
the ten locales, the icon names, the nav key lengths. An uploaded release is
|
|
735
|
+
a `draft` until approved.
|
|
736
|
+
3. **Approve**, which makes the release installable.
|
|
737
|
+
4. **Install.** The operator picks the avatars (for an avatar-scoped plugin),
|
|
738
|
+
reads what the plugin declares, and fills in the three configuration
|
|
739
|
+
sections. Install writes the menu and permission rows, provisions the schema,
|
|
740
|
+
applies the migrations and starts the backend — unless a required backend
|
|
741
|
+
setting is missing, in which case it lands in `awaiting_config`.
|
|
742
|
+
5. **Upgrade** by installing a newer release over the same plugin id. The screen
|
|
743
|
+
shows what this version discloses beyond the accepted one. Stored
|
|
744
|
+
configuration is re-validated against the new schema.
|
|
745
|
+
|
|
746
|
+
A **downgrade is refused** before anything is written: a release that does not
|
|
747
|
+
contain a migration this tenant has already applied cannot be installed, because
|
|
748
|
+
the data those migrations produced cannot be rolled back automatically.
|
|
749
|
+
Uninstall first if the intent is to start over — which does drop the schema.
|
|
750
|
+
|
|
751
|
+
### One command, for iteration and CI
|
|
752
|
+
|
|
753
|
+
Steps 1–4 are also a single non-interactive command. It packs the current tree,
|
|
754
|
+
streams the archive through the same presigned upload the screen uses, finalizes
|
|
755
|
+
the release, approves it, and installs it onto the tenant:
|
|
756
|
+
|
|
757
|
+
```bash
|
|
758
|
+
pnpm exec tego-plugin publish \
|
|
759
|
+
--server http://localhost:3100 \
|
|
760
|
+
--token "$TEGO_ADMIN_TOKEN" \
|
|
761
|
+
--avatar av_123 # repeat per avatar; avatar-scoped plugins only
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
`--server` and `--token` fall back to `TEGO_SERVER_URL` and `TEGO_ADMIN_TOKEN`.
|
|
765
|
+
The token is an admin session's bearer — the publish endpoints all sit behind
|
|
766
|
+
the same admin guard as the screen. `--no-approve` stops at a draft;
|
|
767
|
+
`--no-install` approves without installing. There is no separate downgrade or
|
|
768
|
+
config path here: those decisions belong on the screen, and this command
|
|
769
|
+
deliberately does not reach for them.
|
|
770
|
+
|
|
771
|
+
### 通用 AI 表单填写(Host API 1.9)
|
|
772
|
+
|
|
773
|
+
宿主为所有插件页面提供统一悬浮按钮。插件登记当前可编辑表单,无需实现 AI 输入框、模型调用或聊天窗口。支持 React、Vue 及原生前端;`frontend` 入口不依赖 React。
|
|
774
|
+
|
|
775
|
+
```ts
|
|
776
|
+
const unregister = ctx.ai?.registerForm({
|
|
777
|
+
id: 'inventory-edit',
|
|
778
|
+
title: '库存调整',
|
|
779
|
+
element: formElement,
|
|
780
|
+
getSnapshot: () => ({
|
|
781
|
+
fields: [
|
|
782
|
+
{ name: 'quantity', label: '数量', type: 'number', value: state.quantity },
|
|
783
|
+
{ name: 'note', label: '说明', type: 'text', value: state.note },
|
|
784
|
+
],
|
|
785
|
+
instructions: '数量使用件,不推测未提供的数量。',
|
|
786
|
+
}),
|
|
787
|
+
apply: values => updateLocalForm(values),
|
|
788
|
+
});
|
|
789
|
+
// 关闭表单/卸载组件时调用;不要在每次输入变化时重新注册。
|
|
790
|
+
unregister?.();
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
`element` 必须已经挂载。React 的弹窗可能延迟挂载 portal,建议用 callback ref 取得元素,再在 effect 内登记。`getSnapshot` 必须读最新状态;React 可以用 ref 保存当前表单值。
|
|
794
|
+
|
|
795
|
+
字段类型为 `text`、`number`、`boolean`、`date`、`select`;枚举提供 `options: [{ value, label }]`。只登记当前可见、可编辑、允许提供给分身的字段;不登记密码、令牌、隐藏字段。业务枚举和规则由插件声明,SDK 不内置任何具体插件的数据模型。
|
|
796
|
+
|
|
797
|
+
宿主调用所选分身的独立 model-only 运行,使用完成回执回填,不发聊天消息、不赋予工具写权限。回填前检查字段类型和枚举,保留运行期间手工编辑的字段;关闭表单或切换分身会取消,迟到结果丢弃。嵌套表单优先作用于最后登记的表单,关闭后恢复上一张。
|
|
798
|
+
|
|
799
|
+
`apply` 只更新本地状态,不能提交或写数据库。用户继续通过原表单的校验和保存路径提交。没有登记表单的旧插件仍能显示统一入口,但会提示先打开已接入的表单。`ctx.ai` 是可选能力,可兼容旧宿主;需要保证可用的插件应声明 `hostApiRange: ^1.9.0`。
|
|
800
|
+
|
|
801
|
+
单次填写通过有长度上限的运行回执返回,宿主要求简洁 JSON;过长、格式错误或不属于当前表单的输出会拒绝回填,不能保存部分损坏结果。
|
|
802
|
+
|
|
803
|
+
## 受管下载(Host API 1.15)
|
|
804
|
+
|
|
805
|
+
Host API 1.20.0 增加可选 `host.diagrams`:安装声明 `diagrams.render` 后,
|
|
806
|
+
调用 `status()` 检查渲染器就绪,再以 `render({ kind: 'mermaid' | 'html', source,
|
|
807
|
+
destinationKey, width?, height? })` 取得 PNG 的命名空间内 key、尺寸、哈希和渲染器版本。
|
|
808
|
+
宿主不接收服务器路径或远程页面地址;HTML 只允许静态结构和受限样式,Mermaid 禁止
|
|
809
|
+
配置覆盖与链接。渲染隔离、外网关闭、画布越界报错,默认限时 15 秒、并发 2。
|
|
810
|
+
插件在后台任务保存幂等键、租约和章节版本,并重新校验项目权限后发布结果。
|
|
811
|
+
|
|
812
|
+
需要下载时重新授权的插件声明 `storage.managed`,并使用
|
|
813
|
+
`host().storage.managedPath(key, policy, ttlSeconds)`;Python 对应
|
|
814
|
+
`host().storage.managed_path(key, policy, ttl_seconds)`。policy 包含主体、客户、
|
|
815
|
+
resourceType/resourceId 和 purpose(view 或 export)。有效期最多 300 秒。
|
|
816
|
+
|
|
817
|
+
每次 GET 或 Range 请求开始前,宿主向固定
|
|
818
|
+
`POST /api/internal/storage/authorize` 发送带安装签名的策略。
|
|
819
|
+
插件必须实时核验授权及 key 与资源的归属,并返回 `{ allowed: true }` 才能下载。
|
|
820
|
+
SDK 单独验签并提供 storage policy 上下文;该上下文没有普通用户或公开身份,
|
|
821
|
+
且不能访问其他业务路由。旧 signedPath 继续兼容未声明受管存储的插件。
|
|
822
|
+
|
|
823
|
+
该机制约束每次新下载;已经通过授权而开始的传输可以完成。策略回调失败、
|
|
824
|
+
超时或拒绝时,宿主不打开文件流。
|
|
825
|
+
|
|
826
|
+
### 打开插件的 Office 产物
|
|
827
|
+
|
|
828
|
+
Host API 1.21.0 提供可选 `ctx.office.open({ sourcePath, signal? })`。`sourcePath`
|
|
829
|
+
只能指向当前插件自己的后端元数据接口;接口先校验业务权限,再返回
|
|
830
|
+
`{ fileName, sha256, sizeBytes, downloadPath }`。`downloadPath` 必须由
|
|
831
|
+
`storage.managedPath` 签发,宿主复核安装所属插件、实时下载权限、文件长度和 SHA-256,
|
|
832
|
+
不接收本机路径、外部 URL 或资料库文档 ID。
|
|
833
|
+
|
|
834
|
+
Office 打开该字节快照的独立副本,保存不会写回插件正式产物。分页为近似效果,
|
|
835
|
+
打印验收仍由对应 Office 应用完成。单次预览读取配额为 64 MiB,超过时应选择分册
|
|
836
|
+
或下载到本地;此限制不影响插件保存和导出长文。旧宿主没有 `ctx.office`,插件须做
|
|
837
|
+
能力检测并保留下载入口。
|
|
838
|
+
|
|
839
|
+
### 直接注册普通用户
|
|
840
|
+
|
|
841
|
+
Host API 1.17.0 提供 `host.accounts.provisionUser`(Python:`host().accounts.provision_user`),需声明 `accounts.provision`。传入 `requestId`、`organizationName` 及用户身份;新用户同时传入 `password`,至少 8 位并包含大小写字母和数字。平台使用安装上下文的租户,复用配额和普通用户角色校验,在同一事务保存密码哈希及幂等结果,不发送邮件。已有用户可通过 `userId` 指定,或由平台按该租户邮箱匹配;已有账号的密码及身份资料不被覆盖。返回 `{ id, userId }`。插件须在调用前验证操作者的企业管理权限,且不持久化明文密码。
|
|
842
|
+
|
|
843
|
+
Host API 1.18.0 提供 host.accounts.resetPassword({ userId, newPassword })(Python:host().accounts.reset_password(user_id, new_password)),须声明 accounts.provision。租户由安装凭证确定;仅允许重置有效普通平台账号,平台管理员账号必须在 Tego 平台管理。密码至少 8 位并包含大小写字母和数字。宿主复用正式密码服务,本地账号下次登录必须修改密码,第三方登录账号按平台认证规则处理。插件必须先校验操作者对目标企业成员的管理权限,不能持久化或记录明文密码。
|
|
844
|
+
|
|
845
|
+
### 用事件启动员工的定时任务
|
|
846
|
+
|
|
847
|
+
Host API 1.26.0 提供可选 `host.proactive.emit({ userId, eventType, eventId, occurredAtMs?, payload? })`(Python:`host().proactive.emit(user_id=..., event_type=..., event_id=...)`),须声明 `proactive.emit` 并有后端部分,`hostApiRange` 至少 `^1.26.0`。宿主把来源固定为 `plugin:<pluginId>`,只启动该用户在本租户已启用、`schedule` 为 `{ kind: 'event', source: 'plugin:<pluginId>', eventType }` 的定时任务;返回 `{ source, runs }`,每个命中的任务一条结果。同一 `eventId` 重发返回已排队的运行;任务正在运行时返回 `reason: 'already-running'`,本次不排队。`payload` 以外部不可信内容交给员工。详见 [plugin-sdk-api.md](../../plugins/docs/plugin-sdk-api.md#事件触发任务)。
|