@ashley-shrok/viewmodel-shell 1.5.0 → 1.7.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/agent-skill.md +121 -0
- package/dist/browser.d.ts +4 -1
- package/dist/browser.js +41 -14
- package/dist/index.d.ts +6 -5
- package/dist/server.d.ts +57 -0
- package/dist/server.js +80 -0
- package/package.json +2 -1
package/agent-skill.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# ViewModel Shell — agent operating manual
|
|
2
|
+
|
|
3
|
+
This is the protocol manual for an agent driving a ViewModel Shell (VMS) app over its JSON wire. It is operational, not historical. Follow each section as a rule.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
VMS is a server-driven UI framework. The server is a pure function `(state, action) → (newState, view)`. Every response carries the entire UI state as an opaque blob and a fresh `vm` view tree. You do not have to render the tree to drive the API — you can read its node types and dispatch actions directly.
|
|
8
|
+
|
|
9
|
+
## Endpoints
|
|
10
|
+
|
|
11
|
+
Read the page's `<meta name="viewmodel-shell">` tag for the endpoint pair:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{ "protocol": "viewmodel-shell/1.0", "endpoint": "/api/x", "actionEndpoint": "/api/x/action", "skill": "/.well-known/vms-skill.md" }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `GET <endpoint>` → returns the initial `{ "ok": true, "vm": <ViewNode tree>, "state": <opaque state blob> }`.
|
|
18
|
+
- `POST <actionEndpoint>` → dispatches an action. See the next section for the body shape.
|
|
19
|
+
|
|
20
|
+
The `skill` field is optional and points at this manual (or an app-specific preamble + this manual).
|
|
21
|
+
|
|
22
|
+
## Action dispatch shape
|
|
23
|
+
|
|
24
|
+
Two body forms are accepted. Use JSON when you can; multipart only when uploading files.
|
|
25
|
+
|
|
26
|
+
**JSON (recommended for agents).** Set `Content-Type: application/json` and POST a flat envelope:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{ "name": "save-ticket-42", "state": { "...": "round-tripped state blob from the last response" } }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
There is no `context` field — per-row / per-tab identity is encoded in the action name itself (e.g. `delete-row-42`, `filter-active`). File uploads are NOT supported in this form.
|
|
33
|
+
|
|
34
|
+
**Multipart (browser-style and file-bearing).** Set `Content-Type: multipart/form-data` and post three kinds of entries:
|
|
35
|
+
|
|
36
|
+
| Field | Value |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `_action` | JSON: `{"name": "<action-name>"}` |
|
|
39
|
+
| `_state` | JSON: the current state blob |
|
|
40
|
+
| any file-input `name` | the binary file content (one entry per file input) |
|
|
41
|
+
|
|
42
|
+
## The round-trip rule
|
|
43
|
+
|
|
44
|
+
The `state` blob from the last response goes back unchanged on the next dispatch, EXCEPT for the fields the user changed. Input nodes carry a `bind` property whose value is a dotted path inside `state` where the input's value lives (e.g. `"bind": "form.title"`). Before dispatching an action that depends on user input, write your value at the bound path inside the state blob; leave every other field as you received it. The server is authoritative for everything else and may rewrite any field in the response.
|
|
45
|
+
|
|
46
|
+
## Response envelope
|
|
47
|
+
|
|
48
|
+
Every response carries `ok: boolean`.
|
|
49
|
+
|
|
50
|
+
**Success:**
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{ "ok": true, "vm": { /* ViewNode tree */ }, "state": { /* opaque blob */ } }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Optional success-path fields, which may appear alone or alongside `vm`/`state`:
|
|
57
|
+
|
|
58
|
+
| Field | Meaning |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `redirect` | A URL string. Navigate to it (or hand control off). When `redirect` is present, `vm` and `state` may be omitted. |
|
|
61
|
+
| `sideEffects` | An array of side-effect verbs (see next section). Applied in order before any redirect or re-render. |
|
|
62
|
+
| `nextPollIn` | Milliseconds. Schedule a `{"name": "poll"}` dispatch after this delay. |
|
|
63
|
+
| `busy` | Boolean. While `true`, drop user-initiated dispatches. Polls bypass. The next response that omits or sets `false` clears the lock. |
|
|
64
|
+
| `preventUnload` | Boolean. While `true`, treat the page as having unsaved work — warn before navigating away. |
|
|
65
|
+
|
|
66
|
+
**Failure:**
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{ "ok": false, "errors": [ { "message": "...", "code": "parse_error", "path": "form.title" } ] }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`errors[].path` and `errors[].code` are optional. HTTP status is 4xx or 5xx on failure. Check `ok` once at the response edge; do not branch on HTTP status.
|
|
73
|
+
|
|
74
|
+
## Side-effect verbs
|
|
75
|
+
|
|
76
|
+
`sideEffects[]` entries each carry a `type` discriminator. Built-in verbs as of `viewmodel-shell/1.0`:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{ "sideEffects": [
|
|
80
|
+
{ "type": "set-local-storage", "key": "auth_jwt", "value": "eyJ..." },
|
|
81
|
+
{ "type": "set-session-storage", "key": "draft_id", "value": "42" },
|
|
82
|
+
{ "type": "download", "url": "/api/invoices/42/pdf", "filename": "invoice-42.pdf" }
|
|
83
|
+
] }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Verb | Effect |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `set-local-storage` | Write `key`/`value` to platform localStorage (or your agent's equivalent persistent store). |
|
|
89
|
+
| `set-session-storage` | Same as above for session-scoped storage. |
|
|
90
|
+
| `download` | Fetch `url` (re-presenting your auth headers — see *Auth*) and save the bytes. Filename precedence: `Content-Disposition` > side-effect `filename` > URL basename > `"download"`. |
|
|
91
|
+
|
|
92
|
+
**Forward-compat rule — silently ignore unknown verbs.** A future minor release may add new verbs. If you see a `type` you do not recognize, skip it; do not error. Honor or ignore per your policy.
|
|
93
|
+
|
|
94
|
+
## Errors
|
|
95
|
+
|
|
96
|
+
`ok: false` responses always carry `errors[]`. The framework uses a stable code vocabulary at the protocol edge:
|
|
97
|
+
|
|
98
|
+
| Code | Meaning |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `parse_error` | The request body could not be parsed (malformed JSON, missing required field). |
|
|
101
|
+
| `unknown_action` | The `name` in your action envelope does not match any handler in the current tree. |
|
|
102
|
+
| `invalid_tree` | The server built a tree that violates a wire invariant (this is a server bug, not yours). |
|
|
103
|
+
| `uncaught_exception` | The action handler threw. Treat as a 500-class failure. |
|
|
104
|
+
|
|
105
|
+
Stop on `ok: false`. Surface the message to the user. Do not retry blindly — most of these are deterministic.
|
|
106
|
+
|
|
107
|
+
## Auth
|
|
108
|
+
|
|
109
|
+
The wire does not mandate an auth shape. If the app needs credentials, the app preamble above (or its own README) names them. Common patterns: a `Bearer` token in `Authorization`, a CSRF/anti-forgery token in a custom header, a session cookie. Send the same headers on every request, including polls and downloads. The `download` side-effect re-presents your auth headers when the agent fetches the file.
|
|
110
|
+
|
|
111
|
+
## Polling
|
|
112
|
+
|
|
113
|
+
If a response carries `nextPollIn: N`, schedule a POST `{ "name": "poll", "state": <last state> }` against the same `actionEndpoint` after `N` milliseconds. The server may continue returning `nextPollIn` until the workflow reaches a terminal state, at which point the field will be absent. Polls run silently — they are not user-initiated.
|
|
114
|
+
|
|
115
|
+
## Files
|
|
116
|
+
|
|
117
|
+
File uploads use the multipart form above. One form entry per file input, keyed by the input's `name` attribute (from the corresponding node's `name` field in the tree). The file's binary content is the entry's value. JSON-body dispatch cannot carry files; use multipart.
|
|
118
|
+
|
|
119
|
+
## Versioning
|
|
120
|
+
|
|
121
|
+
This manual applies to protocol token `viewmodel-shell/1.0` — the value of the `protocol` field on the discoverability meta tag. The protocol token tracks the wire shape, NOT the package version: a 1.5.x or 1.6.x package release may still carry protocol `viewmodel-shell/1.0` because the wire has not undergone a breaking change. A future major-version bump (`viewmodel-shell/2.0`) signals a breaking change and invalidates this manual; expect a new skill at the same `/.well-known/vms-skill.md` URL.
|
package/dist/browser.d.ts
CHANGED
|
@@ -52,7 +52,10 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
52
52
|
* every keystroke writes, Enter dispatches filterAction; pagination
|
|
53
53
|
* prev/next write the target page to paginationBind then dispatch
|
|
54
54
|
* prevAction/nextAction. Per-row controls (row.actions[]) are a mix of
|
|
55
|
-
* ButtonNode and CheckboxNode
|
|
55
|
+
* ButtonNode and CheckboxNode; the renderer partitions them by entry.type —
|
|
56
|
+
* CheckboxNodes render in a dedicated LEADING column (left, the data-grid
|
|
57
|
+
* selection convention), ButtonNodes in the TRAILING actions cell (right).
|
|
58
|
+
* When row.action
|
|
56
59
|
* is set, the entire <tr> becomes clickable + keyboard-activatable
|
|
57
60
|
* (Enter / Space — Space preventDefaults page scroll) and exposes
|
|
58
61
|
* role="button", tabindex=0, and an aria-label derived from cell text;
|
package/dist/browser.js
CHANGED
|
@@ -781,7 +781,10 @@ export class BrowserAdapter {
|
|
|
781
781
|
* every keystroke writes, Enter dispatches filterAction; pagination
|
|
782
782
|
* prev/next write the target page to paginationBind then dispatch
|
|
783
783
|
* prevAction/nextAction. Per-row controls (row.actions[]) are a mix of
|
|
784
|
-
* ButtonNode and CheckboxNode
|
|
784
|
+
* ButtonNode and CheckboxNode; the renderer partitions them by entry.type —
|
|
785
|
+
* CheckboxNodes render in a dedicated LEADING column (left, the data-grid
|
|
786
|
+
* selection convention), ButtonNodes in the TRAILING actions cell (right).
|
|
787
|
+
* When row.action
|
|
785
788
|
* is set, the entire <tr> becomes clickable + keyboard-activatable
|
|
786
789
|
* (Enter / Space — Space preventDefaults page scroll) and exposes
|
|
787
790
|
* role="button", tabindex=0, and an aria-label derived from cell text;
|
|
@@ -795,6 +798,17 @@ export class BrowserAdapter {
|
|
|
795
798
|
table.className = "vms-table";
|
|
796
799
|
const thead = document.createElement("thead");
|
|
797
800
|
const headerRow = document.createElement("tr");
|
|
801
|
+
// Per-row checkboxes render in a dedicated LEADING column. If ANY row
|
|
802
|
+
// carries a checkbox, every row gets a leading select cell (empty when the
|
|
803
|
+
// row has none) and the header/filter rows get a matching leading <th>, so
|
|
804
|
+
// body cells stay column-aligned with their headers. (The trailing actions
|
|
805
|
+
// cell needs no header because it's the LAST column — a leading column does.)
|
|
806
|
+
const tableHasCheckboxes = n.rows.some(r => r.actions?.some(e => e.type === "checkbox") ?? false);
|
|
807
|
+
if (tableHasCheckboxes) {
|
|
808
|
+
const selTh = document.createElement("th");
|
|
809
|
+
selTh.className = "vms-table__th vms-table__th--select";
|
|
810
|
+
headerRow.appendChild(selTh);
|
|
811
|
+
}
|
|
798
812
|
const sortIntent = (n.sortBind != null ? this.sa.read(n.sortBind) : null);
|
|
799
813
|
const sortedCol = sortIntent?.column;
|
|
800
814
|
const sortedDir = sortIntent?.direction;
|
|
@@ -832,6 +846,9 @@ export class BrowserAdapter {
|
|
|
832
846
|
const filterAction = n.filterAction;
|
|
833
847
|
const filterRow = document.createElement("tr");
|
|
834
848
|
filterRow.className = "vms-table__filter-row";
|
|
849
|
+
if (tableHasCheckboxes) {
|
|
850
|
+
filterRow.appendChild(document.createElement("th"));
|
|
851
|
+
}
|
|
835
852
|
n.columns.forEach(col => {
|
|
836
853
|
const th = document.createElement("th");
|
|
837
854
|
if (col.filterable) {
|
|
@@ -898,6 +915,22 @@ export class BrowserAdapter {
|
|
|
898
915
|
}
|
|
899
916
|
});
|
|
900
917
|
}
|
|
918
|
+
// Leading select cell — holds this row's checkbox controls (empty when
|
|
919
|
+
// the row has none). Rendered for every row whenever the table has any
|
|
920
|
+
// checkboxes, so columns line up with the leading <th> added above. When
|
|
921
|
+
// row.action is set, swallow clicks so toggling doesn't fire the row action.
|
|
922
|
+
if (tableHasCheckboxes) {
|
|
923
|
+
const selTd = document.createElement("td");
|
|
924
|
+
selTd.className = "vms-table__td vms-table__td--select";
|
|
925
|
+
for (const entry of row.actions ?? []) {
|
|
926
|
+
if (entry.type === "checkbox")
|
|
927
|
+
this.checkbox(entry, selTd, on);
|
|
928
|
+
}
|
|
929
|
+
if (row.action) {
|
|
930
|
+
selTd.addEventListener("click", (e) => { e.stopPropagation(); });
|
|
931
|
+
}
|
|
932
|
+
tr.appendChild(selTd);
|
|
933
|
+
}
|
|
901
934
|
n.columns.forEach(col => {
|
|
902
935
|
const td = document.createElement("td");
|
|
903
936
|
td.className = "vms-table__td";
|
|
@@ -921,21 +954,15 @@ export class BrowserAdapter {
|
|
|
921
954
|
}
|
|
922
955
|
tr.appendChild(td);
|
|
923
956
|
});
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
|
|
928
|
-
if (
|
|
957
|
+
// Trailing actions cell — per-row ButtonNodes only (checkboxes render in
|
|
958
|
+
// the leading select cell above). When row.action is set, swallow clicks
|
|
959
|
+
// on the actions td so pressing a button doesn't ALSO fire the row action.
|
|
960
|
+
const buttonEntries = (row.actions ?? []).filter((e) => e.type === "button");
|
|
961
|
+
if (buttonEntries.length > 0) {
|
|
929
962
|
const td = document.createElement("td");
|
|
930
963
|
td.className = "vms-table__td vms-table__td--actions";
|
|
931
|
-
for (const entry of
|
|
932
|
-
|
|
933
|
-
this.button(entry, td, on);
|
|
934
|
-
else if (entry.type === "checkbox")
|
|
935
|
-
this.checkbox(entry, td, on);
|
|
936
|
-
// forward-compatible: unknown entry types no-op (the closed TS union
|
|
937
|
-
// narrows; the runtime guard is for late-arriving wire shapes).
|
|
938
|
-
}
|
|
964
|
+
for (const entry of buttonEntries)
|
|
965
|
+
this.button(entry, td, on);
|
|
939
966
|
if (row.action) {
|
|
940
967
|
td.addEventListener("click", (e) => { e.stopPropagation(); });
|
|
941
968
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -320,11 +320,12 @@ export interface TableRow {
|
|
|
320
320
|
* per-row button, checkbox, or cell linkLabel anchor does NOT also fire
|
|
321
321
|
* `row.action` (the renderer stops propagation on those targets). */
|
|
322
322
|
action?: ActionEvent;
|
|
323
|
-
/** Per-row interactive controls
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
323
|
+
/** Per-row interactive controls. Each entry is either a ButtonNode (its own
|
|
324
|
+
* unique action name encodes per-row identity, e.g. `delete-row-42`) or a
|
|
325
|
+
* CheckboxNode (its own `bind` path per row). The renderer partitions by
|
|
326
|
+
* `entry.type`: CheckboxNodes render in a dedicated LEADING column (left —
|
|
327
|
+
* the data-grid selection convention), ButtonNodes in the TRAILING actions
|
|
328
|
+
* cell (right). A previous version typed this as `ButtonNode[]` and called
|
|
328
329
|
* the button renderer blindly, silently dropping non-button entries. */
|
|
329
330
|
actions?: (ButtonNode | CheckboxNode)[];
|
|
330
331
|
variant?: string;
|
package/dist/server.d.ts
CHANGED
|
@@ -68,6 +68,63 @@ export declare const shellSideEffect: {
|
|
|
68
68
|
* JSON wire, matching the .NET WhenWritingNull null-omission contract. */
|
|
69
69
|
download: (url: string, filename?: string) => ShellSideEffect;
|
|
70
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* Mount the canonical VMS agent skill markdown as an HTTP handler.
|
|
73
|
+
*
|
|
74
|
+
* The skill is a self-contained operating manual for the VMS wire protocol
|
|
75
|
+
* (action dispatch shape, state round-trip rules, response envelope vocabulary,
|
|
76
|
+
* side-effect verbs, polling, errors, file uploads). Advertise it to agents
|
|
77
|
+
* driving your app via the `skill` field on the
|
|
78
|
+
* `<meta name="viewmodel-shell">` discoverability tag, pointing at whatever URL
|
|
79
|
+
* you mount this handler at (recommended: `/.well-known/vms-skill.md`).
|
|
80
|
+
*
|
|
81
|
+
* **Canonical source:** `viewmodel-shell/agent-skill.md` (shipped in the npm
|
|
82
|
+
* package's `files` array; the .NET package embeds a byte-identical copy at
|
|
83
|
+
* `viewmodel-shell-dotnet/AgentSkill.md` — see `parity/check-skill.ts`).
|
|
84
|
+
*
|
|
85
|
+
* **Preamble shape.** When `appPreamble` is supplied (non-empty after trim),
|
|
86
|
+
* the served body is:
|
|
87
|
+
*
|
|
88
|
+
* ```
|
|
89
|
+
* ## App-specific notes
|
|
90
|
+
*
|
|
91
|
+
* <preamble verbatim>
|
|
92
|
+
*
|
|
93
|
+
* ---
|
|
94
|
+
*
|
|
95
|
+
* <canonical-skill-body verbatim>
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* When omitted (or whitespace-only), the served body is the canonical skill
|
|
99
|
+
* verbatim. The body is computed ONCE at handler-creation time; per-request
|
|
100
|
+
* cost is a single `new Response(body)`. Multiple handlers with different
|
|
101
|
+
* preambles are cheap and fully independent.
|
|
102
|
+
*
|
|
103
|
+
* **Cross-runtime.** Pure Web Fetch API — works in Bun, Deno, Hono, Cloudflare
|
|
104
|
+
* Workers, and Node 18+. The application's router owns method/path routing;
|
|
105
|
+
* the handler accepts any request and unconditionally serves the markdown body
|
|
106
|
+
* with `Content-Type: text/markdown; charset=utf-8`.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```ts
|
|
110
|
+
* import { createAgentSkillHandler } from "@ashley-shrok/viewmodel-shell/server";
|
|
111
|
+
* const skillHandler = createAgentSkillHandler({
|
|
112
|
+
* appPreamble: "This is the foo app. Auth: Bearer JWT in Authorization.",
|
|
113
|
+
* });
|
|
114
|
+
* Bun.serve({
|
|
115
|
+
* async fetch(req) {
|
|
116
|
+
* const url = new URL(req.url);
|
|
117
|
+
* if (url.pathname === "/.well-known/vms-skill.md" && req.method === "GET") {
|
|
118
|
+
* return skillHandler(req);
|
|
119
|
+
* }
|
|
120
|
+
* // ... your other routes
|
|
121
|
+
* },
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export declare function createAgentSkillHandler(opts?: {
|
|
126
|
+
appPreamble?: string;
|
|
127
|
+
}): (req: Request) => Response;
|
|
71
128
|
/**
|
|
72
129
|
* Thrown by an action handler to signal a malformed/invalid request. The
|
|
73
130
|
* createAction wrapper catches this and returns a 400 with the error
|
package/dist/server.js
CHANGED
|
@@ -8,6 +8,21 @@
|
|
|
8
8
|
// Re-export the ViewNode hierarchy and wire types so a backend can import
|
|
9
9
|
// everything it needs from one place.
|
|
10
10
|
export * from "./index.js";
|
|
11
|
+
// ─── Canonical agent skill load (1.6.0 / 1.5.0) ──────────────────────────────
|
|
12
|
+
// Loaded once at module init so createAgentSkillHandler can bake the body at
|
|
13
|
+
// handler-creation time (no per-request fs work). The path resolution works for
|
|
14
|
+
// BOTH layouts:
|
|
15
|
+
// - Source/dev (vitest): src/server.ts → ../agent-skill.md
|
|
16
|
+
// - Published tarball: dist/server.js → ../agent-skill.md (file is in
|
|
17
|
+
// package root, listed in package.json `files`)
|
|
18
|
+
// If `agent-skill.md` is missing at module init the import throws — the
|
|
19
|
+
// fail-loud rule (see AGENTS.md capability-seam doc) applies: a silent 404
|
|
20
|
+
// from the skill endpoint would defeat the purpose.
|
|
21
|
+
import { readFileSync as __vmsReadFileSync } from "node:fs";
|
|
22
|
+
import { fileURLToPath as __vmsFileURLToPath } from "node:url";
|
|
23
|
+
import { dirname as __vmsDirname, join as __vmsJoin } from "node:path";
|
|
24
|
+
const __vmsAgentSkillDir = __vmsDirname(__vmsFileURLToPath(import.meta.url));
|
|
25
|
+
const AGENT_SKILL_MARKDOWN = __vmsReadFileSync(__vmsJoin(__vmsAgentSkillDir, "..", "agent-skill.md"), "utf8");
|
|
11
26
|
/**
|
|
12
27
|
* Walk a ViewNode tree and assert that every dispatch-bearing action name names
|
|
13
28
|
* exactly one operation. Two occurrences are considered "the same operation"
|
|
@@ -376,6 +391,71 @@ export const shellSideEffect = {
|
|
|
376
391
|
* JSON wire, matching the .NET WhenWritingNull null-omission contract. */
|
|
377
392
|
download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
|
|
378
393
|
};
|
|
394
|
+
// ─── Canonical agent skill mount helper (1.6.0 / 1.5.0) ──────────────────────
|
|
395
|
+
/**
|
|
396
|
+
* Mount the canonical VMS agent skill markdown as an HTTP handler.
|
|
397
|
+
*
|
|
398
|
+
* The skill is a self-contained operating manual for the VMS wire protocol
|
|
399
|
+
* (action dispatch shape, state round-trip rules, response envelope vocabulary,
|
|
400
|
+
* side-effect verbs, polling, errors, file uploads). Advertise it to agents
|
|
401
|
+
* driving your app via the `skill` field on the
|
|
402
|
+
* `<meta name="viewmodel-shell">` discoverability tag, pointing at whatever URL
|
|
403
|
+
* you mount this handler at (recommended: `/.well-known/vms-skill.md`).
|
|
404
|
+
*
|
|
405
|
+
* **Canonical source:** `viewmodel-shell/agent-skill.md` (shipped in the npm
|
|
406
|
+
* package's `files` array; the .NET package embeds a byte-identical copy at
|
|
407
|
+
* `viewmodel-shell-dotnet/AgentSkill.md` — see `parity/check-skill.ts`).
|
|
408
|
+
*
|
|
409
|
+
* **Preamble shape.** When `appPreamble` is supplied (non-empty after trim),
|
|
410
|
+
* the served body is:
|
|
411
|
+
*
|
|
412
|
+
* ```
|
|
413
|
+
* ## App-specific notes
|
|
414
|
+
*
|
|
415
|
+
* <preamble verbatim>
|
|
416
|
+
*
|
|
417
|
+
* ---
|
|
418
|
+
*
|
|
419
|
+
* <canonical-skill-body verbatim>
|
|
420
|
+
* ```
|
|
421
|
+
*
|
|
422
|
+
* When omitted (or whitespace-only), the served body is the canonical skill
|
|
423
|
+
* verbatim. The body is computed ONCE at handler-creation time; per-request
|
|
424
|
+
* cost is a single `new Response(body)`. Multiple handlers with different
|
|
425
|
+
* preambles are cheap and fully independent.
|
|
426
|
+
*
|
|
427
|
+
* **Cross-runtime.** Pure Web Fetch API — works in Bun, Deno, Hono, Cloudflare
|
|
428
|
+
* Workers, and Node 18+. The application's router owns method/path routing;
|
|
429
|
+
* the handler accepts any request and unconditionally serves the markdown body
|
|
430
|
+
* with `Content-Type: text/markdown; charset=utf-8`.
|
|
431
|
+
*
|
|
432
|
+
* @example
|
|
433
|
+
* ```ts
|
|
434
|
+
* import { createAgentSkillHandler } from "@ashley-shrok/viewmodel-shell/server";
|
|
435
|
+
* const skillHandler = createAgentSkillHandler({
|
|
436
|
+
* appPreamble: "This is the foo app. Auth: Bearer JWT in Authorization.",
|
|
437
|
+
* });
|
|
438
|
+
* Bun.serve({
|
|
439
|
+
* async fetch(req) {
|
|
440
|
+
* const url = new URL(req.url);
|
|
441
|
+
* if (url.pathname === "/.well-known/vms-skill.md" && req.method === "GET") {
|
|
442
|
+
* return skillHandler(req);
|
|
443
|
+
* }
|
|
444
|
+
* // ... your other routes
|
|
445
|
+
* },
|
|
446
|
+
* });
|
|
447
|
+
* ```
|
|
448
|
+
*/
|
|
449
|
+
export function createAgentSkillHandler(opts = {}) {
|
|
450
|
+
const preamble = opts.appPreamble?.trim() ?? "";
|
|
451
|
+
const body = preamble.length === 0
|
|
452
|
+
? AGENT_SKILL_MARKDOWN
|
|
453
|
+
: `## App-specific notes\n\n${preamble}\n\n---\n\n${AGENT_SKILL_MARKDOWN}`;
|
|
454
|
+
return (_req) => new Response(body, {
|
|
455
|
+
status: 200,
|
|
456
|
+
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
|
457
|
+
});
|
|
458
|
+
}
|
|
379
459
|
// ─── Action handler factory ──────────────────────────────────────────────────
|
|
380
460
|
/**
|
|
381
461
|
* Thrown by an action handler to signal a malformed/invalid request. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"files": [
|
|
22
22
|
"dist",
|
|
23
23
|
"styles",
|
|
24
|
+
"agent-skill.md",
|
|
24
25
|
"README.md",
|
|
25
26
|
"LICENSE"
|
|
26
27
|
],
|