@standardagents/code-plugin-sdk 1.0.0-alpha.0 → 1.0.0-alpha.10-rows.0
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/README.md +182 -7
- package/REFERENCE.md +536 -0
- package/bin/standard-plugin.mjs +149 -0
- package/package.json +3 -2
- package/src/collection.mjs +0 -0
- package/src/index.d.ts +250 -16
- package/src/index.mjs +7 -2
- package/src/internal.d.ts +2 -1
- package/src/manifest.mjs +22 -3
- package/src/protocol.mjs +8 -4
- package/src/publish.mjs +62 -0
- package/src/row-layout.mjs +29 -0
- package/src/runtime.mjs +81 -6
- package/src/testing.d.ts +3 -1
- package/src/testing.mjs +17 -6
- package/src/view.mjs +257 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@standardagents/code-plugin-sdk",
|
|
3
|
-
"version": "1.0.0-alpha.0",
|
|
3
|
+
"version": "1.0.0-alpha.10-rows.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Standard Code plugin authoring SDK",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
".": { "types": "./src/index.d.ts", "import": "./src/index.mjs" },
|
|
16
16
|
"./testing": { "types": "./src/testing.d.ts", "import": "./src/testing.mjs" }
|
|
17
17
|
},
|
|
18
|
-
"
|
|
18
|
+
"bin": { "standard-plugin": "./bin/standard-plugin.mjs" },
|
|
19
|
+
"files": ["src", "bin", "README.md", "REFERENCE.md", "LICENSE"],
|
|
19
20
|
"scripts": { "test": "node --test test/*.test.mjs" }
|
|
20
21
|
}
|
|
Binary file
|
package/src/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
2
2
|
export type Capability = 'surfaces' | 'events' | 'hooks' | 'panes' | 'projects' |
|
|
3
3
|
'notifications' | 'url' | 'fetch' | 'secrets' | 'webhook';
|
|
4
|
-
|
|
4
|
+
/** `card` draws a sidebar card under the `plugins` anchor, with an optional border. */
|
|
5
|
+
export type SurfaceKind = 'section' | 'card' | 'slot' | 'badge' | 'panel' | 'overlay' |
|
|
5
6
|
'menu' | 'command' | 'key' | 'link';
|
|
7
|
+
/** How a panel opens. Absent means `popover`. */
|
|
8
|
+
export type Presentation = 'popover' | 'column' | 'pane';
|
|
6
9
|
export type MenuPosition = 'top' | 'after-open' | 'before-danger' | 'bottom';
|
|
7
10
|
export type Anchor = 'plugins' | 'machine.before' | 'machine.after' |
|
|
8
11
|
'project.before' | 'project.after' | 'pane.header' | 'pane.footer' |
|
|
@@ -11,6 +14,10 @@ export interface ContributionDeclaration {
|
|
|
11
14
|
id: string;
|
|
12
15
|
kind: SurfaceKind;
|
|
13
16
|
anchor: Anchor;
|
|
17
|
+
/** Sidebar card border. Defaults to true; false retains the title and content padding. */
|
|
18
|
+
border?: boolean;
|
|
19
|
+
/** One author-selected Nerd Font glyph; omitted on non-Nerd viewer tiers. */
|
|
20
|
+
icon?: string;
|
|
14
21
|
title?: string;
|
|
15
22
|
merge?: 'by-machine' | 'by-identity';
|
|
16
23
|
width?: 'full' | 'half';
|
|
@@ -19,6 +26,10 @@ export interface ContributionDeclaration {
|
|
|
19
26
|
chord?: string;
|
|
20
27
|
pattern?: string;
|
|
21
28
|
actionId?: string;
|
|
29
|
+
/** How a panel opens (panels), or how the panel named by `opens` opens (cards and commands). */
|
|
30
|
+
presentation?: Presentation;
|
|
31
|
+
/** The id of a declared panel that a card or command opens when activated. */
|
|
32
|
+
opens?: string;
|
|
22
33
|
}
|
|
23
34
|
/** The package.json standardPlugin field is read before any plugin code runs. */
|
|
24
35
|
export interface PluginManifest {
|
|
@@ -62,7 +73,23 @@ export interface TextSpan {
|
|
|
62
73
|
underline?: boolean;
|
|
63
74
|
actionId?: string;
|
|
64
75
|
}
|
|
76
|
+
/** Viewer-resolved grouping and ordered truncation for pane header/footer rows. */
|
|
77
|
+
export interface RowLayout {
|
|
78
|
+
projectTint?: boolean;
|
|
79
|
+
fields: Array<{
|
|
80
|
+
text: string;
|
|
81
|
+
icon?: string;
|
|
82
|
+
fallback?: string;
|
|
83
|
+
tone?: 'muted' | 'ok' | 'error' | 'info' | 'warning' | 'project';
|
|
84
|
+
surface?: boolean;
|
|
85
|
+
bold?: boolean;
|
|
86
|
+
gap?: number;
|
|
87
|
+
flex?: boolean;
|
|
88
|
+
shrink?: { priority: number; min: number; mode: 'start' | 'middle' | 'end' | 'hide' };
|
|
89
|
+
}>;
|
|
90
|
+
}
|
|
65
91
|
export interface NativeRow {
|
|
92
|
+
layout?: RowLayout;
|
|
66
93
|
id: string;
|
|
67
94
|
identity?: string;
|
|
68
95
|
providerRevision?: string;
|
|
@@ -72,17 +99,89 @@ export interface NativeRow {
|
|
|
72
99
|
spark?: number[];
|
|
73
100
|
divider?: boolean;
|
|
74
101
|
}
|
|
75
|
-
export type
|
|
76
|
-
|
|
77
|
-
|
|
102
|
+
export type RowsContent = { kind: 'rows'; rows: NativeRow[] };
|
|
103
|
+
export type TextContent = { kind: 'text'; lines: TextSpan[][] };
|
|
104
|
+
export type BadgeContent = { kind: 'badge'; spans: TextSpan[]; actionId?: string };
|
|
105
|
+
export type NativeContent = RowsContent | TextContent | BadgeContent;
|
|
106
|
+
/** `columns` sets canvas content width. Sidebar card borders fill the sidebar; columns and panes size to content. */
|
|
107
|
+
export interface CanvasThemeColor {
|
|
108
|
+
/** ANSI palette index 0..15; omitted uses the viewer's default foreground. */
|
|
109
|
+
source?: number;
|
|
110
|
+
/** Optional second ANSI palette index, mixed equally with source. */
|
|
111
|
+
mix?: number;
|
|
112
|
+
/** Blend against the viewer background, from 0 to 1. */
|
|
113
|
+
opacity: number;
|
|
114
|
+
}
|
|
78
115
|
export interface CanvasSpec {
|
|
116
|
+
/** Up to 32 canvas-local indexed color recipes. Keys are ANSI indexes 0..255. */
|
|
117
|
+
themeColors?: Record<number, CanvasThemeColor>;
|
|
79
118
|
columns: number;
|
|
80
119
|
rows: number;
|
|
81
120
|
transparent?: boolean;
|
|
82
121
|
shade?: number;
|
|
83
122
|
captureInput?: boolean;
|
|
123
|
+
/** A bounded one-line string or styled spans shown while the pointer rests over the canvas. */
|
|
124
|
+
hover?: string | TextSpan[];
|
|
84
125
|
}
|
|
85
|
-
export type
|
|
126
|
+
export type CanvasContent = { kind: 'canvas'; canvas: CanvasSpec };
|
|
127
|
+
/**
|
|
128
|
+
* What an `onInput` handler for a canvas receives. Keys arrive while the canvas
|
|
129
|
+
* declares `captureInput` and holds focus; focus loss arrives when the host
|
|
130
|
+
* stops showing it.
|
|
131
|
+
*/
|
|
132
|
+
export type CanvasInputEvent =
|
|
133
|
+
| { kind: 'key'; key: string; phase: 'press' | 'repeat'; contributionId: string; entity?: EntityRef }
|
|
134
|
+
| { kind: 'focus'; focused: false; contributionId: string; entity?: EntityRef };
|
|
135
|
+
|
|
136
|
+
/** Semantic tones. The host maps each tone to the viewer's theme. */
|
|
137
|
+
export type Tone = 'ok' | 'info' | 'warn' | 'error' | 'muted' | 'accent' | 'pending' | 'bright';
|
|
138
|
+
export type TextWeight = 'normal' | 'bold' | 'dim';
|
|
139
|
+
export type Align = 'start' | 'center' | 'end';
|
|
140
|
+
/** The host sends `actionId` and `value` to the plugin; `opens` names a panel the host opens after the plugin accepts. */
|
|
141
|
+
export interface ViewAction { actionId: string; value?: string; opens?: string }
|
|
142
|
+
export interface ViewSpan { text: string; tone?: Tone; weight?: TextWeight; mono?: boolean }
|
|
143
|
+
export interface StackNode { type: 'stack'; gap?: number; children?: ViewNode[] }
|
|
144
|
+
export interface RowNode { type: 'row'; children?: ViewNode[]; align?: Align }
|
|
145
|
+
export interface DividerNode { type: 'divider'; label?: string }
|
|
146
|
+
/** Either `text` or `spans`; a node with both draws `text` first. */
|
|
147
|
+
export interface TextNode { type: 'text'; text?: string; spans?: ViewSpan[]; tone?: Tone; weight?: TextWeight; mono?: boolean }
|
|
148
|
+
export interface BadgeNode { type: 'badge'; label: string; tone?: Tone }
|
|
149
|
+
export interface DotNode { type: 'dot'; tone?: Tone }
|
|
150
|
+
export interface Meter { value: number; max: number; tone?: Tone; label?: string }
|
|
151
|
+
export interface ProgressNode extends Meter { type: 'progress' }
|
|
152
|
+
export interface SegmentsNode { type: 'segments'; items: Meter[] }
|
|
153
|
+
/** A boxed group inside a panel or section view. A card contribution's view cannot contain one. */
|
|
154
|
+
export interface CardNode { type: 'card'; title?: string; tone?: Tone; children?: ViewNode[] }
|
|
155
|
+
export interface StatNode { type: 'stat'; label: string; value: string; tone?: Tone; hint?: string }
|
|
156
|
+
/** `copy` marks a value the viewer can copy from the focused row. */
|
|
157
|
+
export interface KvItem { label: string; value: string; mono?: boolean; copy?: boolean }
|
|
158
|
+
export interface KvNode { type: 'kv'; items: KvItem[] }
|
|
159
|
+
export interface TabItem { id: string; label: string; sublabel?: string; tone?: Tone; count?: number; tag?: string }
|
|
160
|
+
/**
|
|
161
|
+
* With `filters` naming a table in the same view, the host shows only rows whose `tags` contain the
|
|
162
|
+
* chosen item's `tag`; an item without `tag` shows every row. `action` also notifies the plugin, and
|
|
163
|
+
* its value defaults to the chosen item id.
|
|
164
|
+
*/
|
|
165
|
+
export interface TabsNode { type: 'tabs'; id: string; items: TabItem[]; filters?: string; action?: ViewAction }
|
|
166
|
+
export interface SelectOption { id: string; label: string; group?: string; count?: number; tag?: string }
|
|
167
|
+
export interface SelectNode { type: 'select'; id: string; label: string; options: SelectOption[]; filters?: string; action?: ViewAction }
|
|
168
|
+
/** Lower `priority` values stay visible longest when the host drops columns to fit. */
|
|
169
|
+
export interface TableColumn { id: string; label?: string; width?: 'fill' | number; maxWidth?: number; align?: Align; priority?: number }
|
|
170
|
+
/** A missing cell draws empty. `note` is a secondary line; `tags` feed host-side filters. */
|
|
171
|
+
export interface TableRow { id: string; cells?: Record<string, ViewNode>; tone?: Tone; note?: ViewSpan[]; tags?: string[]; action?: ViewAction }
|
|
172
|
+
export interface TableNode { type: 'table'; id: string; columns: TableColumn[]; rows?: TableRow[] }
|
|
173
|
+
export interface LogNode { type: 'log'; lines: string[] }
|
|
174
|
+
export interface ButtonNode { type: 'button'; label: string; action: ViewAction }
|
|
175
|
+
export type ViewNode = StackNode | RowNode | DividerNode | TextNode | BadgeNode | DotNode | ProgressNode |
|
|
176
|
+
SegmentsNode | CardNode | StatNode | KvNode | TabsNode | SelectNode | TableNode | LogNode | ButtonNode;
|
|
177
|
+
/** A host-rendered view tree. Cards, panels, and sections accept it. */
|
|
178
|
+
export interface ViewContent { kind: 'view'; root: ViewNode; /** Passive styled title for card views. */ title?: TextSpan[] }
|
|
179
|
+
|
|
180
|
+
/** Slots and overlays accept rows, text, or canvas. */
|
|
181
|
+
export type DrawnContent = RowsContent | TextContent | CanvasContent;
|
|
182
|
+
/** Sections, cards, and panels also accept a view. */
|
|
183
|
+
export type PanelContent = DrawnContent | ViewContent;
|
|
184
|
+
export type SurfaceContent = NativeContent | CanvasContent | ViewContent;
|
|
86
185
|
export interface RequestOptions { signal?: AbortSignal; timeoutMs?: number }
|
|
87
186
|
export interface Disposable { dispose(): void }
|
|
88
187
|
export type Cleanup = () => void | Promise<void>;
|
|
@@ -106,6 +205,7 @@ export interface PaneCreate {
|
|
|
106
205
|
contributionId?: string;
|
|
107
206
|
}
|
|
108
207
|
export interface PaneResult { pane: EntityRef; operationId: string }
|
|
208
|
+
/** A view action delivers its string `value`; other selections may carry any JSON value. */
|
|
109
209
|
export interface Selection { entity?: EntityRef; actionId: string; value?: Json }
|
|
110
210
|
export interface LinkSelection extends Selection { url: string }
|
|
111
211
|
export type ActionHandler = (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>;
|
|
@@ -147,6 +247,54 @@ export interface PluginEvent {
|
|
|
147
247
|
data: Json;
|
|
148
248
|
deliveryId?: string;
|
|
149
249
|
}
|
|
250
|
+
/** The native release this machine runs. */
|
|
251
|
+
export type BuildInfo = {
|
|
252
|
+
version: string;
|
|
253
|
+
/** The 40-character lowercase commit SHA. */
|
|
254
|
+
commit: string;
|
|
255
|
+
/** The Git ref of the build's source, such as `refs/heads/main`, or null when it is not known. */
|
|
256
|
+
ref: string | null;
|
|
257
|
+
channel: 'branch' | 'canary' | 'production' | 'team' | null;
|
|
258
|
+
};
|
|
259
|
+
/** The account-wide build policy every machine in the fleet follows. */
|
|
260
|
+
export type FleetPolicy = {
|
|
261
|
+
mode: 'follow' | 'pin' | 'production' | null;
|
|
262
|
+
npmTag: string | null;
|
|
263
|
+
/** The Git ref of the followed channel, or null when it is not known. */
|
|
264
|
+
ref: string | null;
|
|
265
|
+
/** The pinned or target version, or null when it is not known. */
|
|
266
|
+
version: string | null;
|
|
267
|
+
};
|
|
268
|
+
/** A local project available to plugins on its owning machine. */
|
|
269
|
+
export interface PluginProjectContext { entity: EntityRef; name: string; path: string }
|
|
270
|
+
/** Pane context with its configured project launch directory. */
|
|
271
|
+
export interface PluginPaneContext { entity: EntityRef; projectId: string; name: string; cwd: string }
|
|
272
|
+
/** The `context.get` result. `build` and `fleet` are null when unknown. */
|
|
273
|
+
export type PluginContextInfo = {
|
|
274
|
+
accountId: string;
|
|
275
|
+
machineId: string;
|
|
276
|
+
projects: PluginProjectContext[];
|
|
277
|
+
panes: PluginPaneContext[];
|
|
278
|
+
/** False when local records could not be included or decoded. */
|
|
279
|
+
complete: boolean;
|
|
280
|
+
build?: BuildInfo | null;
|
|
281
|
+
fleet?: FleetPolicy | null;
|
|
282
|
+
};
|
|
283
|
+
/** Replaces the local pane/project context after an account change. */
|
|
284
|
+
export type PaneContextEventData = Pick<PluginContextInfo, 'machineId' | 'projects' | 'panes' | 'complete'>;
|
|
285
|
+
export interface PaneContextEvent extends PluginEvent {
|
|
286
|
+
name: 'pane-context';
|
|
287
|
+
data: PaneContextEventData;
|
|
288
|
+
}
|
|
289
|
+
/** The `data` of a `build-context` event. */
|
|
290
|
+
export type BuildContextEventData = {
|
|
291
|
+
build: BuildInfo | null;
|
|
292
|
+
fleet: FleetPolicy | null;
|
|
293
|
+
};
|
|
294
|
+
export interface BuildContextEvent extends PluginEvent {
|
|
295
|
+
name: 'build-context';
|
|
296
|
+
data: BuildContextEventData;
|
|
297
|
+
}
|
|
150
298
|
export interface Popover {
|
|
151
299
|
kind: 'chooser' | 'form' | 'confirm';
|
|
152
300
|
title: string;
|
|
@@ -171,7 +319,8 @@ export interface OperationMap {
|
|
|
171
319
|
'config.get': { input: Record<string, never>; output: Record<string, Json> };
|
|
172
320
|
'state.get': { input: { key: string }; output: Json };
|
|
173
321
|
'state.set': { input: { key: string; value: Json }; output: null };
|
|
174
|
-
'
|
|
322
|
+
'state.keys': { input: Record<string, never>; output: string[] };
|
|
323
|
+
'context.get': { input: Record<string, never>; output: PluginContextInfo };
|
|
175
324
|
'popover.open': { input: Popover; output: { choiceId?: string; values?: Record<string, Json>; confirmationId?: string } | null };
|
|
176
325
|
'canvas.write': { input: { key: ContributionKey; ansi: string }; output: null };
|
|
177
326
|
'canvas.focus': { input: { key: ContributionKey; capture: boolean }; output: null };
|
|
@@ -186,8 +335,9 @@ export interface OperationMap {
|
|
|
186
335
|
export type OperationName = keyof OperationMap;
|
|
187
336
|
export type Operation = { [K in OperationName]: { op: K; args: OperationMap[K]['input'] } }[OperationName];
|
|
188
337
|
export type Request = <K extends OperationName>(op: K, args: OperationMap[K]['input'], options?: RequestOptions) => Promise<OperationMap[K]['output']>;
|
|
189
|
-
export interface Publisher extends Disposable {
|
|
190
|
-
|
|
338
|
+
export interface Publisher<C extends SurfaceContent = SurfaceContent> extends Disposable {
|
|
339
|
+
/** Throws a PluginError when the content breaks a protocol bound or does not suit the contribution kind. */
|
|
340
|
+
replace(content: C): void;
|
|
191
341
|
clear(): void;
|
|
192
342
|
}
|
|
193
343
|
export interface Canvas extends Publisher {
|
|
@@ -206,11 +356,13 @@ export interface PluginContext {
|
|
|
206
356
|
readonly producer: Readonly<Producer>;
|
|
207
357
|
readonly signal: AbortSignal;
|
|
208
358
|
request: Request;
|
|
209
|
-
section(id: string, entity?: EntityRef): Publisher
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
359
|
+
section(id: string, entity?: EntityRef): Publisher<PanelContent>;
|
|
360
|
+
card(id: string, entity?: EntityRef): Publisher<PanelContent>;
|
|
361
|
+
slot(id: string, entity: EntityRef): Publisher<DrawnContent>;
|
|
362
|
+
badge(id: string, entity: EntityRef): Publisher<BadgeContent>;
|
|
363
|
+
panel(id: string, entity?: EntityRef): Publisher<PanelContent>;
|
|
364
|
+
overlay(id: string, entity?: EntityRef): Publisher<DrawnContent>;
|
|
365
|
+
/** Publishes plugin-drawn content to any section, card, slot, panel, or overlay declaration. */
|
|
214
366
|
canvas(id: string, spec: CanvasSpec, entity?: EntityRef): Canvas;
|
|
215
367
|
/** IDs match static manifest contributions. Options override declaration defaults. */
|
|
216
368
|
menu(id: string, options: MenuOptions, handler: ActionHandler): Subscription;
|
|
@@ -224,9 +376,12 @@ export interface PluginContext {
|
|
|
224
376
|
key(id: string, handler: ActionHandler): KeyRegistration;
|
|
225
377
|
link(id: string, options: LinkOptions, handler: LinkHandler): Subscription;
|
|
226
378
|
link(id: string, handler: LinkHandler): Subscription;
|
|
379
|
+
onEvent(name: 'pane-context', handler: (event: PaneContextEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
|
|
380
|
+
onEvent(name: 'build-context', handler: (event: BuildContextEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
|
|
227
381
|
onEvent(name: string, handler: (event: PluginEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
|
|
228
382
|
onHook(name: string, handler: (event: HookEvent, context: HandlerContext) => HookResult | Promise<HookResult>): Subscription;
|
|
229
383
|
onAction(name: string, handler: (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>): Subscription;
|
|
384
|
+
/** For a canvas, `name` is its contribution ID and events are `CanvasInputEvent` values. */
|
|
230
385
|
onInput(name: string, handler: (event: Json, context: HandlerContext) => void | Promise<void>): Subscription;
|
|
231
386
|
onSelect(name: string, handler: (event: Selection, context: HandlerContext) => void | Promise<void>): Subscription;
|
|
232
387
|
onResize(name: string, handler: (event: { columns: number; rows: number }, context: HandlerContext) => void | Promise<void>): Subscription;
|
|
@@ -248,9 +403,9 @@ export interface PluginContext {
|
|
|
248
403
|
fetch(args: OperationMap['fetch']['input'], options?: RequestOptions): Promise<OperationMap['fetch']['output']>;
|
|
249
404
|
secrets: { get(name: string, options?: RequestOptions): Promise<string | null> };
|
|
250
405
|
config: { get(options?: RequestOptions): Promise<Record<string, Json>> };
|
|
251
|
-
/**
|
|
252
|
-
state: { get(key: string, options?: RequestOptions): Promise<Json>; set(key: string, value: Json, options?: RequestOptions): Promise<null> };
|
|
253
|
-
context: { get(options?: RequestOptions): Promise<
|
|
406
|
+
/** Per-plugin key and value storage, held in the user's account and shared across their machines: 256 keys per plugin, 64 KiB per value. `keys()` returns the sorted key names. */
|
|
407
|
+
state: { get(key: string, options?: RequestOptions): Promise<Json>; set(key: string, value: Json, options?: RequestOptions): Promise<null>; keys(options?: RequestOptions): Promise<string[]> };
|
|
408
|
+
context: { get(options?: RequestOptions): Promise<PluginContextInfo> };
|
|
254
409
|
popover: { open(args: Popover, options?: RequestOptions): Promise<OperationMap['popover.open']['output']> };
|
|
255
410
|
health: { set(args: OperationMap['health.set']['input'], options?: RequestOptions): Promise<null> };
|
|
256
411
|
webhook: { ack(deliveryId: string, options?: RequestOptions): Promise<null> };
|
|
@@ -262,3 +417,82 @@ export interface PluginDefinition {
|
|
|
262
417
|
export function definePlugin(definition: PluginDefinition): Readonly<PluginDefinition>;
|
|
263
418
|
export function validateManifest(value: unknown): Readonly<PluginManifest>;
|
|
264
419
|
export class PluginError extends Error { code: string; constructor(code: string, message: string) }
|
|
420
|
+
export const PRESENTATIONS: readonly Presentation[];
|
|
421
|
+
export const TONES: readonly Tone[];
|
|
422
|
+
export const VIEW_LIMITS: Readonly<{ depth: 8; nodes: 4096; tableRows: 512; tableColumns: 12; items: 512; logLines: 2048; cardLines: 6; actionValueBytes: 512 }>;
|
|
423
|
+
/**
|
|
424
|
+
* Throws a PluginError when a view tree breaks a protocol bound or a daemon rule. Unknown node types pass.
|
|
425
|
+
* `kind: 'card'` adds the card rules; `manifest` requires each action `opens` to name one of its panels.
|
|
426
|
+
*/
|
|
427
|
+
export function validateView(root: unknown, options?: { kind?: SurfaceKind; manifest?: Pick<PluginManifest, 'contributions'> }): void;
|
|
428
|
+
type Options<T> = Omit<T, 'type' | 'children'>;
|
|
429
|
+
/** Optional builders. Each returns the plain protocol JSON for one node; hand-written JSON is equivalent. */
|
|
430
|
+
export const ui: {
|
|
431
|
+
view(root: ViewNode, options?: { title?: TextSpan[] }): ViewContent;
|
|
432
|
+
action(actionId: string, options?: { value?: string; opens?: string }): ViewAction;
|
|
433
|
+
span(text: string, options?: Omit<ViewSpan, 'text'>): ViewSpan;
|
|
434
|
+
stack(children: ViewNode[], options?: Options<StackNode>): StackNode;
|
|
435
|
+
row(children: ViewNode[], options?: Options<RowNode>): RowNode;
|
|
436
|
+
divider(label?: string): DividerNode;
|
|
437
|
+
/** A string sets `text`; an array of spans sets `spans`. */
|
|
438
|
+
text(content: string | ViewSpan[], options?: Omit<TextNode, 'type' | 'text' | 'spans'>): TextNode;
|
|
439
|
+
badge(label: string, tone?: Tone): BadgeNode;
|
|
440
|
+
dot(tone?: Tone): DotNode;
|
|
441
|
+
progress(value: number, max: number, options?: Omit<Meter, 'value' | 'max'>): ProgressNode;
|
|
442
|
+
segments(items: Meter[]): SegmentsNode;
|
|
443
|
+
card(children: ViewNode[], options?: Options<CardNode>): CardNode;
|
|
444
|
+
stat(label: string, value: string, options?: Omit<StatNode, 'type' | 'label' | 'value'>): StatNode;
|
|
445
|
+
kv(items: KvItem[]): KvNode;
|
|
446
|
+
tabs(id: string, items: TabItem[], options?: Omit<TabsNode, 'type' | 'id' | 'items'>): TabsNode;
|
|
447
|
+
select(id: string, label: string, options: SelectOption[], extra?: Omit<SelectNode, 'type' | 'id' | 'label' | 'options'>): SelectNode;
|
|
448
|
+
table(id: string, columns: TableColumn[], rows?: TableRow[]): TableNode;
|
|
449
|
+
log(lines: string[]): LogNode;
|
|
450
|
+
/** `action` is an action object or an action id. */
|
|
451
|
+
button(label: string, action: ViewAction | string): ButtonNode;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/** One plugin inside a collection. The path is relative to the source root. */
|
|
455
|
+
export interface PluginCollectionEntry {
|
|
456
|
+
id: string;
|
|
457
|
+
/** Relative POSIX path; "" names the source root for a single-plugin source. */
|
|
458
|
+
path: string;
|
|
459
|
+
}
|
|
460
|
+
/** The standard-plugins.json file at the root of a repository or npm package. */
|
|
461
|
+
export interface PluginCollection {
|
|
462
|
+
schema: 1;
|
|
463
|
+
plugins: PluginCollectionEntry[];
|
|
464
|
+
}
|
|
465
|
+
/** File name of the collection manifest: standard-plugins.json. */
|
|
466
|
+
export const COLLECTION_FILE: 'standard-plugins.json';
|
|
467
|
+
export function validateCollection(value: unknown): Readonly<PluginCollection>;
|
|
468
|
+
/** A collection file wins; without one, a valid package manifest makes the root one plugin at path "". */
|
|
469
|
+
export function resolveCollection(source: { collection?: unknown; packageJson?: unknown }): Readonly<PluginCollection>;
|
|
470
|
+
|
|
471
|
+
export type SourceKind = 'git' | 'npm';
|
|
472
|
+
export type LockfileName = 'package-lock.json' | 'npm-shrinkwrap.json';
|
|
473
|
+
export interface LockfileRequirement {
|
|
474
|
+
/** True when the package declares dependencies or optionalDependencies. */
|
|
475
|
+
required: boolean;
|
|
476
|
+
dependencies: readonly string[];
|
|
477
|
+
/** Lockfile names that satisfy the rule for this source kind. */
|
|
478
|
+
lockfiles: readonly LockfileName[];
|
|
479
|
+
}
|
|
480
|
+
/** Package name of this SDK, which a plugin lists under peerDependencies. */
|
|
481
|
+
export const SDK_PACKAGE: '@standardagents/code-plugin-sdk';
|
|
482
|
+
export function lockfileRequirement(input: { packageJson: unknown; sourceKind: SourceKind }): LockfileRequirement;
|
|
483
|
+
export interface PublishProblem {
|
|
484
|
+
code: 'invalid_package' | 'package_private' | 'lockfile_missing' | 'sdk_dependency' | 'sdk_peer_missing' |
|
|
485
|
+
'manifest_missing' | 'invalid_manifest' | 'invalid_payload' | 'payload_too_large' | 'incompatible_version' |
|
|
486
|
+
'entry_missing' | 'id_mismatch';
|
|
487
|
+
message: string;
|
|
488
|
+
}
|
|
489
|
+
/** Pure checks over a package.json and the relative paths of the files that ship with it. */
|
|
490
|
+
export function checkPackageForPublish(input: {
|
|
491
|
+
packageJson: unknown;
|
|
492
|
+
files: readonly string[];
|
|
493
|
+
sourceKind?: SourceKind;
|
|
494
|
+
/** False for a collection root whose package.json carries no plugin manifest. */
|
|
495
|
+
requireManifest?: boolean;
|
|
496
|
+
/** The collection entry id the manifest must match. */
|
|
497
|
+
expectedId?: string;
|
|
498
|
+
}): readonly PublishProblem[];
|
package/src/index.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
|
|
1
|
+
import { PRESENTATIONS, ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
|
|
2
|
+
import { COLLECTION_FILE, resolveCollection, validateCollection } from './collection.mjs'
|
|
3
|
+
import { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement } from './publish.mjs'
|
|
2
4
|
|
|
3
|
-
export { validateManifest, PluginError }
|
|
5
|
+
export { PRESENTATIONS, validateManifest, PluginError }
|
|
6
|
+
export { COLLECTION_FILE, resolveCollection, validateCollection }
|
|
7
|
+
export { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement }
|
|
8
|
+
export { TONES, VIEW_LIMITS, ui, validateView } from './view.mjs'
|
|
4
9
|
|
|
5
10
|
export function definePlugin(definition) {
|
|
6
11
|
ensure(definition && identifier(definition.id) && typeof definition.activate === 'function',
|
package/src/internal.d.ts
CHANGED
|
@@ -30,7 +30,8 @@ export interface Runtime {
|
|
|
30
30
|
}
|
|
31
31
|
export function createRuntime(options: { manifest: PluginManifest; producer: Producer; send(frame: Envelope): void; clock?: Clock; instanceId?: string; onError?(error: Error): void }): Runtime;
|
|
32
32
|
export function authorize(operation: Operation, capabilities: string[]): void;
|
|
33
|
-
export function
|
|
33
|
+
export function frameLimit(frame: unknown, inbound: boolean): number;
|
|
34
|
+
export function validateEnvelope(frame: unknown, options?: { inbound?: boolean }): Envelope;
|
|
34
35
|
export class RpcPeer {
|
|
35
36
|
constructor(options: { producer: Producer; send(frame: Envelope): void; clock?: Clock; idPrefix?: string; onRequest?(operation: Operation | HostOperation, options: { signal: AbortSignal; timeoutMs: number }): Promise<Json> | Json; onSurface?(frame: Envelope): void; onError?(error: Error): void });
|
|
36
37
|
request(operation: Operation | HostOperation, options?: RequestOptions): Promise<Json>;
|
package/src/manifest.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
export const CAPABILITIES = Object.freeze(['surfaces', 'events', 'hooks', 'panes', 'projects',
|
|
2
2
|
'notifications', 'url', 'fetch', 'secrets', 'webhook'])
|
|
3
|
-
export const SURFACE_KINDS = Object.freeze(['section', 'slot', 'badge', 'panel', 'overlay',
|
|
3
|
+
export const SURFACE_KINDS = Object.freeze(['section', 'card', 'slot', 'badge', 'panel', 'overlay',
|
|
4
4
|
'menu', 'command', 'key', 'link'])
|
|
5
|
+
export const PRESENTATIONS = Object.freeze(['popover', 'column', 'pane'])
|
|
5
6
|
export const ANCHORS = Object.freeze(['plugins', 'machine.before', 'machine.after',
|
|
6
7
|
'project.before', 'project.after', 'pane.header', 'pane.footer',
|
|
7
8
|
'account', 'machine', 'project', 'pane', 'section'])
|
|
9
|
+
// responseFrameBytes admits a host response carrying a 16 MiB fetch body after
|
|
10
|
+
// JSON escaping (PLUGIN_FETCH_RESULT_MAX_BYTES in standardd).
|
|
8
11
|
export const LIMITS = Object.freeze({ manifestBytes: 65536, frameBytes: 262144,
|
|
12
|
+
responseFrameBytes: 64 * 1024 * 1024,
|
|
9
13
|
pendingRequests: 128, subscriptions: 256, schedules: 128, contributions: 256,
|
|
10
14
|
queuedBytes: 4 * 1024 * 1024, hookTimeoutMs: 60000, requestTimeoutMs: 30000,
|
|
11
|
-
canvasColumns: 512, canvasRows: 256
|
|
15
|
+
canvasColumns: 512, canvasRows: 256, canvasHoverBytes: 512,
|
|
16
|
+
stateKeys: 256, stateValueBytes: 64 * 1024 })
|
|
12
17
|
|
|
13
18
|
export class PluginError extends Error {
|
|
14
19
|
constructor(code, message) { super(message); this.name = 'PluginError'; this.code = code }
|
|
@@ -71,14 +76,28 @@ export function validateManifest(value) {
|
|
|
71
76
|
ensure(object(declaration) && identifier(declaration.id) && !ids.has(declaration.id) &&
|
|
72
77
|
SURFACE_KINDS.includes(declaration.kind) && ANCHORS.includes(declaration.anchor), 'invalid_manifest', 'Invalid contribution declaration')
|
|
73
78
|
ids.add(declaration.id)
|
|
79
|
+
ensure(declaration.icon === undefined || (declaration.kind === 'card' && typeof declaration.icon === 'string' &&
|
|
80
|
+
/^[\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]$/u.test(declaration.icon)),
|
|
81
|
+
'invalid_manifest', 'Card icon must be one Nerd Font private-use glyph')
|
|
82
|
+
ensure(declaration.border === undefined || (typeof declaration.border === 'boolean' && declaration.kind === 'card'),
|
|
83
|
+
'invalid_manifest', 'Only sidebar cards declare a boolean border preference')
|
|
74
84
|
for (const [key, choices] of Object.entries({ merge: ['by-machine', 'by-identity'], width: ['full', 'half'],
|
|
75
|
-
position: ['top', 'after-open', 'before-danger', 'bottom'] })) {
|
|
85
|
+
position: ['top', 'after-open', 'before-danger', 'bottom'], presentation: PRESENTATIONS })) {
|
|
76
86
|
ensure(declaration[key] === undefined || choices.includes(declaration[key]), 'invalid_manifest', `Invalid contribution ${key}`)
|
|
77
87
|
}
|
|
78
88
|
for (const key of ['title', 'group', 'chord', 'pattern', 'actionId']) {
|
|
79
89
|
ensure(declaration[key] === undefined || (typeof declaration[key] === 'string' && declaration[key].length <= 512),
|
|
80
90
|
'invalid_manifest', `Invalid contribution ${key}`)
|
|
81
91
|
}
|
|
92
|
+
ensure(declaration.opens === undefined || identifier(declaration.opens), 'invalid_manifest', 'Invalid contribution opens')
|
|
93
|
+
ensure(declaration.presentation === undefined || ['panel', 'card', 'command'].includes(declaration.kind),
|
|
94
|
+
'invalid_manifest', 'Only panels, cards, and commands declare a presentation')
|
|
95
|
+
ensure(declaration.kind !== 'card' || declaration.anchor === 'plugins', 'invalid_manifest', 'Cards use the plugins anchor')
|
|
96
|
+
}
|
|
97
|
+
for (const declaration of manifest.contributions) {
|
|
98
|
+
ensure(declaration.opens === undefined ||
|
|
99
|
+
manifest.contributions.some(item => item.id === declaration.opens && item.kind === 'panel'),
|
|
100
|
+
'invalid_manifest', 'Contribution opens must name a declared panel')
|
|
82
101
|
}
|
|
83
102
|
ensure(!manifest.contributions.length || manifest.capabilities.includes('surfaces'), 'invalid_manifest', 'Contributions require surfaces capability')
|
|
84
103
|
ensure(manifest.configSchema === undefined || object(manifest.configSchema), 'invalid_manifest', 'Configuration schema must be an object')
|
package/src/protocol.mjs
CHANGED
|
@@ -8,7 +8,7 @@ export const OPERATIONS = Object.freeze({
|
|
|
8
8
|
'pane.input': 'panes', 'pane.focus': 'panes', 'pane.wait': 'panes',
|
|
9
9
|
'project.create': 'projects', 'project.remove': 'projects',
|
|
10
10
|
'notification.show': 'notifications', 'url.open': 'url', fetch: 'fetch',
|
|
11
|
-
'secret.get': 'secrets', 'config.get': null, 'state.get': null, 'state.set': null,
|
|
11
|
+
'secret.get': 'secrets', 'config.get': null, 'state.get': null, 'state.set': null, 'state.keys': null,
|
|
12
12
|
'context.get': null, 'popover.open': 'surfaces', 'canvas.write': 'surfaces',
|
|
13
13
|
'canvas.focus': 'surfaces', 'subscription.add': null, 'subscription.remove': null,
|
|
14
14
|
'health.set': null, 'webhook.ack': 'webhook',
|
|
@@ -40,8 +40,12 @@ export function validateProducer(producer) {
|
|
|
40
40
|
export function sameProducer(a, b) {
|
|
41
41
|
return a.pluginId === b.pluginId && a.machineId === b.machineId && a.epoch === b.epoch
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
/** Only an inbound response may exceed the frame limit, so a fetch body can reach the plugin. */
|
|
44
|
+
export function frameLimit(frame, inbound) {
|
|
45
|
+
return inbound && object(frame) && frame.kind === 'response' ? LIMITS.responseFrameBytes : LIMITS.frameBytes
|
|
46
|
+
}
|
|
47
|
+
export function validateEnvelope(frame, { inbound = false } = {}) {
|
|
48
|
+
jsonBytes(frame, frameLimit(frame, inbound))
|
|
45
49
|
ensure(object(frame) && frame.version === RUNNER_PROTOCOL_VERSION, 'incompatible_version', 'Daemon and runner protocol versions differ')
|
|
46
50
|
validateProducer(frame.producer)
|
|
47
51
|
ensure(['request', 'response', 'cancel', 'surface'].includes(frame.kind), 'invalid_payload', 'Unknown runner frame kind')
|
|
@@ -115,7 +119,7 @@ export class RpcPeer {
|
|
|
115
119
|
}
|
|
116
120
|
receive(frame) {
|
|
117
121
|
if (this.closed) return
|
|
118
|
-
validateEnvelope(frame)
|
|
122
|
+
validateEnvelope(frame, { inbound: true })
|
|
119
123
|
ensure(sameProducer(frame.producer, this.producer), 'stale_producer', 'Frame belongs to another plugin instance')
|
|
120
124
|
if (frame.kind === 'surface') { this.onSurface?.(frame); return }
|
|
121
125
|
if (frame.kind === 'response') {
|
package/src/publish.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { PluginError, ensure, object, validateManifest } from './manifest.mjs'
|
|
2
|
+
|
|
3
|
+
export const SDK_PACKAGE = '@standardagents/code-plugin-sdk'
|
|
4
|
+
export const SOURCE_KINDS = Object.freeze(['git', 'npm'])
|
|
5
|
+
const LOCKFILES = Object.freeze({ git: Object.freeze(['package-lock.json', 'npm-shrinkwrap.json']), npm: Object.freeze(['npm-shrinkwrap.json']) })
|
|
6
|
+
|
|
7
|
+
function names(section) { return object(section) ? Object.keys(section) : [] }
|
|
8
|
+
|
|
9
|
+
/** Runtime dependencies are the ones npm installs for a consumer of the package. */
|
|
10
|
+
export function runtimeDependencies(packageJson) {
|
|
11
|
+
ensure(object(packageJson), 'invalid_package', 'package.json must contain an object')
|
|
12
|
+
return [...new Set([...names(packageJson.dependencies), ...names(packageJson.optionalDependencies)])]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A plugin with runtime dependencies ships a lockfile. npm publishes
|
|
17
|
+
* npm-shrinkwrap.json and drops package-lock.json, so an npm source needs the
|
|
18
|
+
* shrinkwrap; a Git source may keep either file.
|
|
19
|
+
*/
|
|
20
|
+
export function lockfileRequirement({ packageJson, sourceKind }) {
|
|
21
|
+
ensure(SOURCE_KINDS.includes(sourceKind), 'invalid_source', 'Source kind must be git or npm')
|
|
22
|
+
const dependencies = runtimeDependencies(packageJson)
|
|
23
|
+
return Object.freeze({ required: dependencies.length > 0, dependencies: Object.freeze(dependencies), lockfiles: LOCKFILES[sourceKind] })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalize(path) { return path.replace(/^\.\//, '') }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pure publish checks for one plugin directory. `files` lists the relative
|
|
30
|
+
* POSIX paths that ship with the package. Returns a list of problems; an empty
|
|
31
|
+
* list means the package passes.
|
|
32
|
+
*/
|
|
33
|
+
export function checkPackageForPublish({ packageJson, files, sourceKind = 'npm', requireManifest = true, expectedId } = {}) {
|
|
34
|
+
ensure(Array.isArray(files) && files.every(file => typeof file === 'string'), 'invalid_source', 'files must list relative paths')
|
|
35
|
+
const problems = []
|
|
36
|
+
const problem = (code, message) => problems.push(Object.freeze({ code, message }))
|
|
37
|
+
if (!object(packageJson)) return Object.freeze([Object.freeze({ code: 'invalid_package', message: 'package.json must contain an object' })])
|
|
38
|
+
const present = new Set(files.map(normalize))
|
|
39
|
+
if (packageJson.private === true && sourceKind === 'npm') problem('package_private', 'package.json marks the package private, so npm refuses to publish it')
|
|
40
|
+
const requirement = lockfileRequirement({ packageJson, sourceKind })
|
|
41
|
+
if (requirement.required && !requirement.lockfiles.some(name => present.has(name))) {
|
|
42
|
+
problem('lockfile_missing', `Dependencies (${requirement.dependencies.join(', ')}) need ${requirement.lockfiles.join(' or ')} beside package.json`)
|
|
43
|
+
}
|
|
44
|
+
for (const section of ['dependencies', 'optionalDependencies']) {
|
|
45
|
+
if (names(packageJson[section]).includes(SDK_PACKAGE)) problem('sdk_dependency', `${SDK_PACKAGE} belongs under peerDependencies, not ${section}`)
|
|
46
|
+
}
|
|
47
|
+
let manifest = null
|
|
48
|
+
if (packageJson.standardPlugin === undefined) {
|
|
49
|
+
if (requireManifest) problem('manifest_missing', 'package.json has no standardPlugin manifest')
|
|
50
|
+
} else {
|
|
51
|
+
try { manifest = validateManifest(packageJson.standardPlugin) } catch (error) {
|
|
52
|
+
if (!(error instanceof PluginError)) throw error
|
|
53
|
+
problem(error.code, error.message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (manifest) {
|
|
57
|
+
if (!names(packageJson.peerDependencies).includes(SDK_PACKAGE)) problem('sdk_peer_missing', `${SDK_PACKAGE} must be listed under peerDependencies`)
|
|
58
|
+
if (!present.has(normalize(manifest.entry))) problem('entry_missing', `Entry ${manifest.entry} is not among the package files`)
|
|
59
|
+
if (expectedId !== undefined && manifest.id !== expectedId) problem('id_mismatch', `Manifest id ${manifest.id} differs from collection entry ${expectedId}`)
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze(problems)
|
|
62
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ensure, object } from './manifest.mjs'
|
|
2
|
+
const forbidden = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
|
|
3
|
+
const validText = (v, n) => typeof v === 'string' && Buffer.byteLength(v) <= n && !forbidden.test(v)
|
|
4
|
+
const only = (v, keys) => Object.keys(v).every(key => keys.includes(key))
|
|
5
|
+
export function validateRowLayout(layout) {
|
|
6
|
+
const valid = object(layout) && only(layout, ['projectTint','fields'])
|
|
7
|
+
&& (layout.projectTint === undefined || typeof layout.projectTint === 'boolean')
|
|
8
|
+
&& Array.isArray(layout.fields) && layout.fields.length > 0 && layout.fields.length <= 32
|
|
9
|
+
ensure(valid, 'invalid_payload', 'Invalid row layout')
|
|
10
|
+
let bytes = 0
|
|
11
|
+
for (const f of layout.fields) {
|
|
12
|
+
ensure(object(f) && only(f, ['text','icon','fallback','tone','surface','bold','gap','flex','shrink'])
|
|
13
|
+
&& validText(f.text, 2048)
|
|
14
|
+
&& (f.icon === undefined || (typeof f.icon === 'string' && /^[\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]$/u.test(f.icon)))
|
|
15
|
+
&& (f.fallback === undefined || (validText(f.fallback,16) && /^[\x20-\x7e]*$/.test(f.fallback)))
|
|
16
|
+
&& (f.tone === undefined || ['muted','ok','error','info','warning','project'].includes(f.tone))
|
|
17
|
+
&& ['surface','bold','flex'].every(k=>f[k] === undefined || typeof f[k] === 'boolean')
|
|
18
|
+
&& (f.gap === undefined || Number.isInteger(f.gap) && f.gap >= 0 && f.gap <= 8),
|
|
19
|
+
'invalid_payload', 'Invalid row field')
|
|
20
|
+
bytes += Buffer.byteLength(f.text)
|
|
21
|
+
if (f.shrink !== undefined) {
|
|
22
|
+
const s = f.shrink
|
|
23
|
+
ensure(object(s) && only(s,['priority','min','mode']) && Number.isInteger(s.priority) && s.priority>=0 && s.priority<=255
|
|
24
|
+
&& Number.isInteger(s.min) && s.min>=0 && s.min<=2048 && ['start','middle','end','hide'].includes(s.mode),
|
|
25
|
+
'invalid_payload', 'Invalid row shrink policy')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
ensure(bytes <= 8192, 'payload_too_large', 'Row layout text exceeds 8192 bytes')
|
|
29
|
+
}
|