create-pracht 0.6.0 → 0.6.2
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/package.json +1 -1
- package/skills/add-auth/SKILL.md +63 -143
- package/skills/add-capabilities/SKILL.md +409 -0
- package/skills/add-content/SKILL.md +242 -0
- package/skills/add-db/SKILL.md +93 -202
- package/skills/add-i18n/SKILL.md +178 -217
- package/skills/add-images/SKILL.md +203 -0
- package/skills/add-observability/SKILL.md +118 -15
- package/skills/add-openapi/SKILL.md +209 -0
- package/skills/audit-a11y/SKILL.md +8 -9
- package/skills/audit-agent-surface/SKILL.md +335 -0
- package/skills/audit-auth/SKILL.md +16 -11
- package/skills/audit-bundles/SKILL.md +56 -12
- package/skills/audit-csrf/SKILL.md +9 -10
- package/skills/audit-deps/SKILL.md +8 -8
- package/skills/audit-headers/SKILL.md +9 -10
- package/skills/audit-islands/SKILL.md +9 -10
- package/skills/audit-loaders/SKILL.md +23 -8
- package/skills/audit-redirects/SKILL.md +9 -10
- package/skills/audit-secrets/SKILL.md +6 -6
- package/skills/audit-seo/SKILL.md +8 -8
- package/skills/audit-shells/SKILL.md +8 -9
- package/skills/configure-isg/SKILL.md +9 -10
- package/skills/migrate-nextjs/SKILL.md +200 -415
- package/skills/pracht-debug/SKILL.md +165 -120
- package/skills/pracht-deploy/SKILL.md +248 -329
- package/skills/pracht-scaffold/SKILL.md +123 -146
- package/skills/pracht-test-api/SKILL.md +10 -10
- package/skills/pre-deploy/SKILL.md +166 -195
- package/skills/scaffold-e2e/SKILL.md +11 -12
- package/skills/scaffold-tests/SKILL.md +10 -12
- package/skills/tune-render-mode/SKILL.md +7 -8
- package/skills/typed-routes/SKILL.md +15 -11
- package/skills/upgrade-pracht/SKILL.md +12 -10
- package/src/index.js +43 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-capabilities
|
|
3
|
+
version: 1.0.2
|
|
4
|
+
description: |
|
|
5
|
+
Expose an app operation as a typed pracht capability — one contract projected
|
|
6
|
+
into direct server calls, an HTTP endpoint, a WebMCP page tool, and a remote MCP
|
|
7
|
+
tool — plus `defineApp({ agents })` trust config, typed clients,
|
|
8
|
+
`<Form capability>`, and `pracht eval` scenarios.
|
|
9
|
+
Use for "add a capability", "expose this to agents", "add an MCP tool", "add
|
|
10
|
+
WebMCP", "serve remote MCP", "make my app agent-callable".
|
|
11
|
+
allowed-tools:
|
|
12
|
+
- Bash
|
|
13
|
+
- Read
|
|
14
|
+
- Write
|
|
15
|
+
- Edit
|
|
16
|
+
- Grep
|
|
17
|
+
- Glob
|
|
18
|
+
- AskUserQuestion
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# Pracht Add Capabilities
|
|
22
|
+
|
|
23
|
+
A capability is a protocol-neutral operation (`docs/CAPABILITIES.md`). Every
|
|
24
|
+
projection runs the identical pipeline, so rules never diverge per transport:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
input validation → named middleware chain → run() → output validation
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Registration is opt-in and private by default: no loader or API route is ever
|
|
31
|
+
inferred as a capability, and an app that registers none ships no capability
|
|
32
|
+
dispatch surface (the build drops ~15 KB gzip of dispatch and verifier code).
|
|
33
|
+
Other agent-facing surfaces such as `llms.txt` remain independent.
|
|
34
|
+
|
|
35
|
+
## Step 1: Decide the contract before writing code
|
|
36
|
+
|
|
37
|
+
Settle these with `AskUserQuestion` when the request is vague:
|
|
38
|
+
|
|
39
|
+
- **Name** — dot-separated segments (`notes.search`); this is the agent-visible
|
|
40
|
+
identity and the MCP tool name (dots become underscores).
|
|
41
|
+
- **Effect** — `read`, `write`, or `destructive`. This drives confirmation
|
|
42
|
+
gating, client revalidation, and MCP annotations. Classify honestly.
|
|
43
|
+
- **Exposure** — private (omit `expose`), `http`, `webmcp` (requires `http`),
|
|
44
|
+
`mcp`. Capabilities are manifest-router only; the pages router has no
|
|
45
|
+
manifest to register them in.
|
|
46
|
+
- **Authorization** — which named middleware runs, and whether the endpoint
|
|
47
|
+
requires a verified agent (`agentPolicy: "require"`).
|
|
48
|
+
|
|
49
|
+
Exposure matrix the runtime, `defineCapability()`, and `pracht verify` all
|
|
50
|
+
enforce:
|
|
51
|
+
|
|
52
|
+
| Effect | `http` | `webmcp` | `mcp` |
|
|
53
|
+
| ------ | ------ | -------- | ----- |
|
|
54
|
+
| `read` / `write` | yes | yes (needs `http`) | yes (needs `agents.mcp`) |
|
|
55
|
+
| `destructive` | yes — always confirmation-gated | rejected | yes — needs `agents.mcp.destructive` **and** a registered approval store |
|
|
56
|
+
|
|
57
|
+
## Step 2: Install and scaffold
|
|
58
|
+
|
|
59
|
+
`create-pracht` does not add the package, because an app without capabilities
|
|
60
|
+
should not carry it:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm install @pracht/capabilities
|
|
64
|
+
pracht generate capability --name notes.search --effect read --expose http,webmcp \
|
|
65
|
+
--description "Find notes whose title or body matches the query."
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The generator writes `src/capabilities/notes-search.ts` with `expose`,
|
|
69
|
+
`effect`, and `input` as inline literals and registers the name in the
|
|
70
|
+
manifest. `--description` is required whenever `--expose` is set — that text is
|
|
71
|
+
the contract an agent reads. It refuses the combinations the runtime rejects
|
|
72
|
+
anyway. The MCP `generate_capability` tool does the same thing.
|
|
73
|
+
|
|
74
|
+
If dispatch answers `500 internal_error` and `pracht inspect capabilities`
|
|
75
|
+
prints capabilities as `unreadable`, the package is missing — that is the
|
|
76
|
+
symptom.
|
|
77
|
+
|
|
78
|
+
## Step 3: Write the capability
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// src/capabilities/notes-search.ts
|
|
82
|
+
import { defineCapability, type CapabilityRunArgs } from "@pracht/capabilities";
|
|
83
|
+
import { searchNotes } from "../server/notes-store.ts";
|
|
84
|
+
|
|
85
|
+
interface SearchInput {
|
|
86
|
+
query: string;
|
|
87
|
+
limit: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default defineCapability({
|
|
91
|
+
title: "Search notes",
|
|
92
|
+
description: "Find notes whose title or body matches the query.",
|
|
93
|
+
input: {
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
query: { type: "string", minLength: 1 },
|
|
97
|
+
limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
|
|
98
|
+
},
|
|
99
|
+
required: ["query"],
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
},
|
|
102
|
+
output: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: { notes: { type: "array", items: { type: "object" } } },
|
|
105
|
+
required: ["notes"],
|
|
106
|
+
},
|
|
107
|
+
effect: "read",
|
|
108
|
+
middleware: ["auth"], // names from the app manifest
|
|
109
|
+
expose: { http: true, webmcp: true },
|
|
110
|
+
// webmcp: { untrustedContent: true } advertises untrustedContentHint for
|
|
111
|
+
// page tools whose results carry user-generated content.
|
|
112
|
+
// agentPolicy: "require", // verified Web Bot Auth agents only —
|
|
113
|
+
// // never combine with webmcp: page-tool
|
|
114
|
+
// // calls are unsigned and would always 401
|
|
115
|
+
async run({ input, context, request, signal }: CapabilityRunArgs<SearchInput>) {
|
|
116
|
+
return { notes: searchNotes(input.query, input.limit) };
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Schema rules that bite:
|
|
122
|
+
|
|
123
|
+
- Only a **subset** of JSON Schema is accepted: `type`, `properties`,
|
|
124
|
+
`required`, `additionalProperties`, `items` (single schema), `enum`, `const`,
|
|
125
|
+
`minimum`, `maximum`, `minLength`, `maxLength`, `default`, plus `title` and
|
|
126
|
+
`description`. `oneOf`, `anyOf`, `allOf`, `$ref`, `pattern`, `format`, and
|
|
127
|
+
tuple `items` throw at definition time — a keyword the validator would ignore
|
|
128
|
+
could widen what an exposed capability accepts.
|
|
129
|
+
- Inputs and outputs are JSON data only. `File`, `Blob`, `Date`, `Map`,
|
|
130
|
+
`undefined`, and cycles are rejected — keep uploads in API routes.
|
|
131
|
+
- `expose`, `effect`, and (for webmcp) `input` must be **inline literals**: the
|
|
132
|
+
browser projection is built by static analysis, and an imported constant or
|
|
133
|
+
spread fails the build.
|
|
134
|
+
- MCP exposure additionally requires both schemas rooted at `type: "object"`.
|
|
135
|
+
- Annotate `run()` with `CapabilityRunArgs<Input>` so TypeScript still infers
|
|
136
|
+
the output; `defineCapability<Input>` alone leaves the output `unknown`.
|
|
137
|
+
|
|
138
|
+
## Step 4: Register it, and configure `agents` only if needed
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// src/routes.ts
|
|
142
|
+
export const app = defineApp({
|
|
143
|
+
capabilities: {
|
|
144
|
+
"notes.search": () => import("./capabilities/notes-search.ts"),
|
|
145
|
+
},
|
|
146
|
+
agents: {
|
|
147
|
+
// Verified agent identity (public keys — safe in the manifest).
|
|
148
|
+
webBotAuth: { policy: "observe", directories: ["https://signature-agent.cloudflare.com"] },
|
|
149
|
+
// Destructive prepare/commit tuning.
|
|
150
|
+
confirmation: { ttlSeconds: 120 },
|
|
151
|
+
// Remote MCP endpoint; without this, `expose.mcp` serves nothing.
|
|
152
|
+
mcp: { serverInfo: { name: "notes", version: "1.0.0" }, instructions: "…" },
|
|
153
|
+
},
|
|
154
|
+
routes: [/* … */],
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Each `agents` sub-option is independent — add only what the app uses. Web Bot
|
|
159
|
+
Auth `policy: "require"` gates capability HTTP endpoints (not pages or API
|
|
160
|
+
routes) with `401 agent_required`; `agentPolicy: "require"` on a capability
|
|
161
|
+
fails closed even when `webBotAuth` is unconfigured.
|
|
162
|
+
|
|
163
|
+
### Authenticating the MCP endpoint (`agents.mcp.auth`)
|
|
164
|
+
|
|
165
|
+
`agents: { mcp: {} }` alone serves an **open** endpoint — anyone who can reach
|
|
166
|
+
the URL can call every `expose.mcp` tool, and authentication is whatever the
|
|
167
|
+
capability's named middleware does with the forwarded `Authorization` header.
|
|
168
|
+
That is fine for a public read surface and wrong for anything scoped to a user.
|
|
169
|
+
|
|
170
|
+
Add `auth` and `/mcp` becomes an OAuth 2.0 protected resource: pracht publishes
|
|
171
|
+
RFC 9728 metadata at `/.well-known/oauth-protected-resource`, answers
|
|
172
|
+
unauthenticated calls with the `WWW-Authenticate` challenge MCP hosts follow,
|
|
173
|
+
and calls your `verify` module. This is what makes a real host (Claude, a
|
|
174
|
+
ChatGPT connector) able to connect at all.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
mcp: {
|
|
178
|
+
serverInfo: { name: "notes", version: "1.0.0" },
|
|
179
|
+
auth: {
|
|
180
|
+
resource: "https://app.example.com/mcp", // absolute; token audience
|
|
181
|
+
authorizationServers: ["https://auth.example.com"],
|
|
182
|
+
scopesSupported: ["notes.read", "notes.write"],
|
|
183
|
+
requiredScopes: ["notes.read"], // optional per-request gate
|
|
184
|
+
verify: () => import("./server/mcp-token.ts"), // server-only module
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Rules to hold the user to:
|
|
190
|
+
|
|
191
|
+
- **`verify` is a module reference, never an inline function.** The manifest is
|
|
192
|
+
bundled into the client; a JWKS client in it would ship to every visitor.
|
|
193
|
+
Put the module in `src/server/` and default-export the verifier function. It
|
|
194
|
+
must live under `src/server/`, `src/middleware/`, or `src/capabilities/` —
|
|
195
|
+
those are the only directories the build globs into the module registry, and
|
|
196
|
+
a verifier anywhere else is never loadable, so every `/mcp` request 401s
|
|
197
|
+
forever. `pracht verify` errors on that, but do not create the file elsewhere.
|
|
198
|
+
If the same suffix exists in more than one registry directory, lookup rejects
|
|
199
|
+
it as ambiguous; use a root-relative reference such as
|
|
200
|
+
`() => import("/src/server/mcp-token.ts")`.
|
|
201
|
+
- **Security option names are exact.** Unknown keys under `agents.mcp` and
|
|
202
|
+
`agents.mcp.auth` are rejected instead of ignored; do not work around the
|
|
203
|
+
error with casts. The MCP path must also differ from every explicit API route
|
|
204
|
+
path, or `pracht verify` rejects the graph and the runtime fails closed with
|
|
205
|
+
500 before the API handler can bypass MCP's gates.
|
|
206
|
+
- **Pracht is not an authorization server.** Do not offer to implement token
|
|
207
|
+
issuance, refresh, or dynamic client registration — those belong to the
|
|
208
|
+
user's identity provider. Write `verify` with their library (`jose` works on
|
|
209
|
+
Workers and Vercel Edge) and **bind `audience` to the `resource` value**, or a
|
|
210
|
+
token minted for another service on the same issuer is accepted.
|
|
211
|
+
- **It fails closed.** `null`, a throw, or a malformed principal all give
|
|
212
|
+
`401 invalid_token`; a missing required scope gives `403 insufficient_scope`.
|
|
213
|
+
When `requiredScopes` is set, every challenge advertises it so hosts request
|
|
214
|
+
the right grant on the first authorization attempt. The verifier receives an
|
|
215
|
+
independent request clone, so reading its JSON-RPC body does not consume the
|
|
216
|
+
body that MCP dispatch reads next.
|
|
217
|
+
- **The principal is `context.tokenAuth`** — a frozen `{ subject, scopes?,
|
|
218
|
+
clientId?, claims? }`, alongside `context.agent`. Use it in named middleware
|
|
219
|
+
and `run()` for per-user authorization; the framework only authenticates. It
|
|
220
|
+
lives on a fresh request-local overlay, leaving an adapter's reused base
|
|
221
|
+
context unchanged. Frozen and sealed ordinary contexts work; native built-ins
|
|
222
|
+
such as `Map` and `Date` must be wrapped in an ordinary context. `claims` is
|
|
223
|
+
frozen shallowly, but the complete principal is request-local so nested
|
|
224
|
+
mutations cannot become stale auth on a later request. The capability audit
|
|
225
|
+
event does not carry it yet, so capture it in named middleware or capability
|
|
226
|
+
code and send it to the same audit sink if MCP calls must be attributable to
|
|
227
|
+
an account. Nested capability calls rebind this field to the transport-verified
|
|
228
|
+
principal, so caller-supplied composition context cannot replace it.
|
|
229
|
+
- `resource` must be the endpoint's **real deployed URL**: absolute, free of
|
|
230
|
+
query/fragment, free of a non-root trailing slash, and exactly matching the
|
|
231
|
+
served endpoint's public path — deploy base included, e.g.
|
|
232
|
+
`https://app.example.com/app/mcp` for an app mounted at `/app/`.
|
|
233
|
+
`resolveApp()` and `pracht verify` reject otherwise. The metadata document
|
|
234
|
+
then lands at the origin root with the base inside the suffix
|
|
235
|
+
(`/.well-known/oauth-protected-resource/app/mcp`); pracht derives it. Require
|
|
236
|
+
HTTPS outside loopback development, and reject authorization-server issuers
|
|
237
|
+
with query strings or fragments. For `mcp.path: "/"`, the resource is the
|
|
238
|
+
deployed app root, including its base; at the origin root use slashless
|
|
239
|
+
`https://app.example.com`. Authenticated requests whose URL is not
|
|
240
|
+
exactly this identifier are redirected to it with `308` before token
|
|
241
|
+
verification. Scope values must use OAuth's printable ASCII grammar (no
|
|
242
|
+
spaces, controls, non-ASCII, quotes, or backslashes).
|
|
243
|
+
- The bare `/.well-known/oauth-protected-resource` path is reserved for
|
|
244
|
+
discovery and cannot be used as `mcp.path`. Production adapters route both
|
|
245
|
+
metadata forms ahead of copied static files.
|
|
246
|
+
- `pracht plan` snapshots the OAuth policy separately from the endpoint path.
|
|
247
|
+
Removing `auth` or a required scope, or trusting another authorization server,
|
|
248
|
+
is a guard weakening even when `/mcp` itself did not move.
|
|
249
|
+
|
|
250
|
+
See `docs/REMOTE_MCP.md` for the metadata document and the full `verify` recipe.
|
|
251
|
+
|
|
252
|
+
## Step 5: Destructive capabilities
|
|
253
|
+
|
|
254
|
+
`destructive` (delete, publish, pay, send, change access) may be exposed over
|
|
255
|
+
`http` and `mcp`, never `webmcp`, and every dispatch is gated:
|
|
256
|
+
|
|
257
|
+
1. Set `PRACHT_CONFIRMATION_SECRET` in the server environment (or call
|
|
258
|
+
`setCapabilityConfirmationSecret()` from `@pracht/core/server`). Without it,
|
|
259
|
+
calls fail closed with `403 confirmation_unavailable` and `pracht verify`
|
|
260
|
+
fails — verify reads the environment, so the variable must be set even when
|
|
261
|
+
the app registers the secret programmatically.
|
|
262
|
+
2. A call without a token answers `409 confirmation_required` with a token
|
|
263
|
+
bound to principal + capability + canonical input + expiry.
|
|
264
|
+
3. The commit repeats the call with byte-identical input plus the confirmation
|
|
265
|
+
header.
|
|
266
|
+
|
|
267
|
+
Be honest about what this buys, and say so to the user
|
|
268
|
+
(`docs/AGENT_TRUST.md`): stateless HMAC cannot prevent replay inside the TTL,
|
|
269
|
+
the calling agent can hand the token straight back to itself, and without Web
|
|
270
|
+
Bot Auth or `setCapabilityApprovalPrincipalResolver()` both phases run as
|
|
271
|
+
`"anonymous"`. Register a `CapabilityApprovalStore` for exactly-once commits,
|
|
272
|
+
and `confirmation: { mode: "human" }` for a real human decision — that mode
|
|
273
|
+
fails closed without both a store and an authenticated principal.
|
|
274
|
+
|
|
275
|
+
`createSqlApprovalStore({ execute })` from `@pracht/core/server` is the
|
|
276
|
+
first-party durable store — no driver dependency, one implementation for
|
|
277
|
+
Postgres, Cloudflare D1, and SQLite/Turso. Pass a parameterized-query function
|
|
278
|
+
and run the migration from `docs/AGENT_TRUST.md`; use `dialect: "postgres"` for
|
|
279
|
+
`$1` placeholders. `createMemoryApprovalStore()` is for tests and development
|
|
280
|
+
only. A non-SQL backend needs atomic conditional writes (Durable Objects,
|
|
281
|
+
Redis — not Cloudflare KV).
|
|
282
|
+
|
|
283
|
+
### Destructive over remote MCP
|
|
284
|
+
|
|
285
|
+
Off by default. To serve one:
|
|
286
|
+
|
|
287
|
+
1. `agents: { mcp: { destructive: true } }` in `defineApp()`.
|
|
288
|
+
2. Register an approval store from a server entry or a capability module, so it
|
|
289
|
+
exists before the graph is served. This is not optional — a token handed to
|
|
290
|
+
the committing agent must be consumable exactly once. The endpoint refuses
|
|
291
|
+
to serve at all when the store, `PRACHT_CONFIRMATION_SECRET`, or (in human
|
|
292
|
+
mode) any resolvable principal is missing; `pracht verify` warns when it
|
|
293
|
+
cannot find the registration in the configured source directories.
|
|
294
|
+
3. The flow is unchanged; only the channel differs. Prepare answers
|
|
295
|
+
`isError: true` with the token in `_meta["io.pracht/error"]`, and the commit
|
|
296
|
+
repeats `tools/call` with identical `arguments` plus
|
|
297
|
+
`_meta["io.pracht/confirmation"]`.
|
|
298
|
+
|
|
299
|
+
Nested `invokeCapability()` under an MCP tool still refuses destructive callees
|
|
300
|
+
unless the tool being served is a destructive capability that already cleared
|
|
301
|
+
prepare/commit.
|
|
302
|
+
|
|
303
|
+
## Step 6: Call it
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
// Server: loaders, API routes, middleware — works for private capabilities too.
|
|
307
|
+
import { invokeCapability } from "@pracht/core/server";
|
|
308
|
+
const result = await invokeCapability("notes.search", { query: "roadmap" }, { request, context, signal });
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// Browser: generated, typed, http-exposed names only.
|
|
313
|
+
import { capabilities, useCapability } from "virtual:pracht/capabilities";
|
|
314
|
+
const result = await capabilities.notes.search({ query: "roadmap" });
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
// One contract for the human form and the agent tool.
|
|
319
|
+
<Form capability="notes.create" onCapabilityResult={(result) => { /* … */ }}>
|
|
320
|
+
<input name="title" />
|
|
321
|
+
<button type="submit">Create</button>
|
|
322
|
+
</Form>
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
- Prefer a loader + `invokeCapability()` for data a page needs on load;
|
|
326
|
+
`useCapability()` dispatches on interaction, never during render.
|
|
327
|
+
- After a successful non-`read` call the route's data revalidates
|
|
328
|
+
automatically (`revalidate: false` opts out).
|
|
329
|
+
- Capability modules are server-only: importing one from client code is a build
|
|
330
|
+
error, because nothing would strip `run()` and its database client out of the
|
|
331
|
+
browser bundle.
|
|
332
|
+
|
|
333
|
+
## Step 7: Types, inspection, and proof
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
pracht typegen # emits src/pracht-capabilities.d.ts
|
|
337
|
+
pracht inspect capabilities --json
|
|
338
|
+
pracht verify --json # contract, exposure, and projection checks
|
|
339
|
+
pracht eval --start "pracht preview"
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Once the declaration exists the compiler rejects unknown names, bad input,
|
|
343
|
+
browser calls to private capabilities, destructive calls without
|
|
344
|
+
`prepare`/`confirm`, and runtime-computed names (assert
|
|
345
|
+
`as HttpCapabilityName`). Re-run `pracht typegen --check` in CI.
|
|
346
|
+
|
|
347
|
+
`pracht eval` runs JSON scenarios against the live app and exits 1 on a failed
|
|
348
|
+
expectation — the repeatable answer to "can an agent actually finish this
|
|
349
|
+
task?". Steps can reference earlier results
|
|
350
|
+
(`$steps[0].error.confirmationToken`) and a scenario-level `signAs` block signs
|
|
351
|
+
every step as a verified agent.
|
|
352
|
+
|
|
353
|
+
A scenario targets the HTTP projection by default; set scenario-level
|
|
354
|
+
`"transport": "mcp"` to run the same steps over the app's remote MCP endpoint
|
|
355
|
+
(`initialize` handshake, then one `tools/call` per step, tool names mapped
|
|
356
|
+
`notes.search` → `notes_search`). Write one of each for any capability with
|
|
357
|
+
`expose.mcp` — passing over HTTP does not prove an MCP host can reach it.
|
|
358
|
+
If `agents.mcp.auth` protects the endpoint, add scenario-level
|
|
359
|
+
`"mcpHeaders": { "authorization": "Bearer …" }`; it applies to `initialize`
|
|
360
|
+
and every later request. Inject test tokens in CI instead of committing real
|
|
361
|
+
credentials. Step-level `headers.authorization` overrides it for one call.
|
|
362
|
+
Expectations are portable: `expect.status` is the capability dispatch status on
|
|
363
|
+
both transports, so the same `{ "ok": false, "status": 400, "errorCode":
|
|
364
|
+
"invalid_input" }` holds either way.
|
|
365
|
+
|
|
366
|
+
Three MCP limits fail loudly rather than silently: a step for a capability
|
|
367
|
+
without `expose.mcp`, a step header other than `authorization` (the projection
|
|
368
|
+
forwards nothing else), and a destructive step whose app has not enabled
|
|
369
|
+
`agents.mcp.destructive` with an approval store. For an exposed destructive MCP
|
|
370
|
+
tool, `confirm` completes the same prepare/commit round trip as HTTP; the token
|
|
371
|
+
travels in the call's `_meta["io.pracht/confirmation"]` field.
|
|
372
|
+
|
|
373
|
+
`createCapabilityTestHost()` from `@pracht/core` covers the same pipeline in
|
|
374
|
+
unit tests without a server.
|
|
375
|
+
|
|
376
|
+
WebMCP specifics `pracht verify` checks for you: tool names must fit the
|
|
377
|
+
spec's grammar (1–128 ASCII `[a-zA-Z0-9_.-]`); an effective
|
|
378
|
+
`agentPolicy: "require"` makes a page tool dead (unsigned browser fetches
|
|
379
|
+
always 401 — warned); descriptions have advisory budgets (~500 chars per
|
|
380
|
+
tool, ~150 per schema parameter). Hosts: the ChatGPT desktop browser enables
|
|
381
|
+
the API itself, but stable Chrome/Edge visitors only get
|
|
382
|
+
`document.modelContext` if the page head carries an origin-trial token — the
|
|
383
|
+
capabilities page on the docs site shows the shell `head()` recipe.
|
|
384
|
+
|
|
385
|
+
For an audit of what the whole agent surface currently exposes, run
|
|
386
|
+
`/audit-agent-surface`.
|
|
387
|
+
|
|
388
|
+
## Rules
|
|
389
|
+
|
|
390
|
+
1. Never expose a `destructive` capability over `webmcp`; expose it over `mcp`
|
|
391
|
+
only with `agents.mcp.destructive` and a durable approval store, and say so
|
|
392
|
+
to the user. Never reclassify a destructive operation as `write` to escape
|
|
393
|
+
the confirmation gate.
|
|
394
|
+
2. Never widen a schema (drop `required`, open `additionalProperties`, raise a
|
|
395
|
+
`maximum`) without saying so — `pracht plan` reports it as a widening of the
|
|
396
|
+
agent-reachable surface for a reason.
|
|
397
|
+
3. Keep `expose`, `effect`, and `input` as inline literals.
|
|
398
|
+
4. Put authentication, authorization, and rate limiting in named middleware —
|
|
399
|
+
the framework ships no rate limiting, no write-idempotency helper, and no
|
|
400
|
+
result-size budget. Bound outputs with a `limit` input and a schema
|
|
401
|
+
`maximum`.
|
|
402
|
+
5. Design `write` inputs to be safely repeatable; agents retry, and only
|
|
403
|
+
`destructive` calls are token-gated.
|
|
404
|
+
6. Never register an app-wide approval endpoint or UI without your own
|
|
405
|
+
authorization — who may approve is an application decision.
|
|
406
|
+
7. Re-run `pracht typegen` after changing a schema, name, or exposure, and
|
|
407
|
+
`pracht verify` before committing.
|
|
408
|
+
|
|
409
|
+
$ARGUMENTS
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-content
|
|
3
|
+
version: 1.0.1
|
|
4
|
+
description: |
|
|
5
|
+
Wire `@pracht/content` and `@pracht/markdown`: a collection registry owning
|
|
6
|
+
source discovery, routes, locales, compilation, and static artifacts
|
|
7
|
+
(`llms.txt`, raw source), plus Markdown/MDX route modules and snapshot loaders.
|
|
8
|
+
Use for "add a blog", "set up docs", "render markdown pages", "add MDX",
|
|
9
|
+
"content collections", "why is my markdown route 404ing".
|
|
10
|
+
allowed-tools:
|
|
11
|
+
- Bash
|
|
12
|
+
- Read
|
|
13
|
+
- Write
|
|
14
|
+
- Edit
|
|
15
|
+
- Grep
|
|
16
|
+
- Glob
|
|
17
|
+
- AskUserQuestion
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Pracht Add Content Collections
|
|
21
|
+
|
|
22
|
+
`@pracht/content` is the optional, **server-only** content layer
|
|
23
|
+
(`docs/CONTENT.md`). It exists so a content-heavy app has *one* registry
|
|
24
|
+
instead of two readers that drift — route modules reading source through Vite,
|
|
25
|
+
and sitemap/`llms.txt`/search plugins scanning the filesystem again.
|
|
26
|
+
`@pracht/markdown` is the opinionated Markdown route-module compiler on top of
|
|
27
|
+
it (`packages/markdown/README.md`).
|
|
28
|
+
|
|
29
|
+
Nothing here is on by default: adding a collection publishes no source and
|
|
30
|
+
creates no agent surface until you add artifacts or capabilities.
|
|
31
|
+
|
|
32
|
+
## Step 1: Pick the shape
|
|
33
|
+
|
|
34
|
+
Ask with `AskUserQuestion` when it is not obvious from the repo:
|
|
35
|
+
|
|
36
|
+
| Situation | Wiring |
|
|
37
|
+
| --------- | ------ |
|
|
38
|
+
| Markdown/MDX files that should *be* pages | `@pracht/markdown` → `defineMarkdownCollection()` |
|
|
39
|
+
| Content that feeds loaders, search, or an API — never a page | `@pracht/content` → `defineCollection()` with `unroutedDocuments: "ignore"` |
|
|
40
|
+
| An app-specific compiler (custom AST, page model, search record) | `defineCollection({ compile, module })` |
|
|
41
|
+
|
|
42
|
+
Also settle: collection root directory, `routeBase`, whether documents are
|
|
43
|
+
localized, and whether relative images appear in the Markdown.
|
|
44
|
+
|
|
45
|
+
## Step 2: Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pnpm add @pracht/content
|
|
49
|
+
pnpm add @pracht/markdown @pracht/image # Markdown route modules
|
|
50
|
+
pnpm add -D sharp # only when documents embed local images
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`sharp` runs at build/dev time only and never ships to a runtime bundle.
|
|
54
|
+
|
|
55
|
+
## Step 3: Define the collection outside the vite config
|
|
56
|
+
|
|
57
|
+
Put the collection in its own module (`content.ts`) so the vite config, build
|
|
58
|
+
plugins, and tests import the same object:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// content.ts
|
|
62
|
+
import { llmsTxtArtifacts, rawContentArtifacts } from "@pracht/content";
|
|
63
|
+
import { defineMarkdownCollection } from "@pracht/markdown";
|
|
64
|
+
|
|
65
|
+
export const docs = defineMarkdownCollection({
|
|
66
|
+
name: "docs",
|
|
67
|
+
root: new URL("./src/routes/docs", import.meta.url),
|
|
68
|
+
routeBase: "/docs",
|
|
69
|
+
// locales: { default: "en", supported: ["en", "fr"] },
|
|
70
|
+
// images: { placeholder: "blur" }, // default "empty" — keeps CSP unchanged
|
|
71
|
+
// snapshot: { raw: false }, // drop a representation you never read
|
|
72
|
+
artifacts: [
|
|
73
|
+
rawContentArtifacts({ path: (document) => `${document.path}.md` }),
|
|
74
|
+
llmsTxtArtifacts({
|
|
75
|
+
title: "Product docs",
|
|
76
|
+
origin: "https://example.com",
|
|
77
|
+
sections: [{ heading: "Docs", match: "/docs" }],
|
|
78
|
+
}),
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Without `sources`, the registry scans `root` recursively for `.md`/`.mdx`;
|
|
84
|
+
`index` files collapse to their directory. Pass explicit
|
|
85
|
+
`{ id, path, source, locale }` entries when routes must not be inferred.
|
|
86
|
+
Duplicate ids, routes, sources, or artifact paths throw at definition time
|
|
87
|
+
rather than letting file order pick a winner.
|
|
88
|
+
|
|
89
|
+
## Step 4: Register the plugins (order matters)
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// vite.config.ts
|
|
93
|
+
import { prachtContent } from "@pracht/content/vite";
|
|
94
|
+
import { prachtImage } from "@pracht/image/vite";
|
|
95
|
+
import { pracht } from "@pracht/vite-plugin";
|
|
96
|
+
import { defineConfig } from "vite";
|
|
97
|
+
import { docs } from "./content";
|
|
98
|
+
|
|
99
|
+
export default defineConfig({
|
|
100
|
+
plugins: [
|
|
101
|
+
prachtContent({ collections: [docs] }), // one call, every collection
|
|
102
|
+
prachtImage(), // relative Markdown images
|
|
103
|
+
pracht(),
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Register **all** collections through a single `prachtContent()` call — the
|
|
109
|
+
plugin owns one internal build manifest and rejects a second registration.
|
|
110
|
+
|
|
111
|
+
## Step 5: Point routes at the documents
|
|
112
|
+
|
|
113
|
+
Markdown modules are ordinary route modules (they export `Component`, `head()`,
|
|
114
|
+
and the raw `markdown` string for `Accept: text/markdown` negotiation):
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
route("/docs/routing", () => import("./routes/docs/routing.md"), {
|
|
118
|
+
id: "routing",
|
|
119
|
+
render: "ssg",
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Sources and routes are still two readers, so `pracht build` reconciles them and
|
|
124
|
+
names every document (and generated locale alias) that no route serves — a
|
|
125
|
+
document without a route still reaches `llms.txt` and `rawContentArtifacts()`
|
|
126
|
+
while the page answers 404. The default policy is `"warn"`:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
prachtContent({ collections: [docs], unroutedDocuments: "error" });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Use `"error"` for a docs site, `"ignore"` for a data-only collection. On a
|
|
133
|
+
static export a dynamic SSG route only covers the concrete paths
|
|
134
|
+
`getStaticPaths()` returns, and a dynamic SPA route only covers deep links when
|
|
135
|
+
`staticAdapter({ fallback })` emits one. `pracht verify` cannot do this check —
|
|
136
|
+
it reads the vite config as text — so run a build before believing the routing
|
|
137
|
+
is complete.
|
|
138
|
+
|
|
139
|
+
## Step 6: Read the collection at request time
|
|
140
|
+
|
|
141
|
+
Loaders, middleware, API routes, and capabilities must import the **generated
|
|
142
|
+
snapshot**, not the filesystem-backed authoring object:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { contentLoader } from "@pracht/content/runtime";
|
|
146
|
+
import docs from "virtual:pracht/content/docs";
|
|
147
|
+
|
|
148
|
+
export const loader = contentLoader(docs, {
|
|
149
|
+
select: (document) => ({ html: document.compiled, title: document.frontmatter.title }),
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- The virtual module is **server-only**; a retained client import fails the
|
|
154
|
+
build instead of shipping source, frontmatter, and compiled values to the
|
|
155
|
+
browser.
|
|
156
|
+
- Every accessor is async: each document's `raw`/`body`/`compiled` lives in its
|
|
157
|
+
own deferred chunk, so the first content-backed request does not parse the
|
|
158
|
+
whole collection. `iterate()` streams one document at a time; `all()` loads
|
|
159
|
+
everything.
|
|
160
|
+
- `contentLoader()` uses the matched, base-free `pathname`, and answers
|
|
161
|
+
unmatchable pathnames (`/docs/%2e%2e`) with its not-found path instead of
|
|
162
|
+
failing the request.
|
|
163
|
+
- `markdownRepresentation(document, "raw" | "body")` produces the server-only
|
|
164
|
+
`markdown` export for content negotiation — it throws when `snapshot` dropped
|
|
165
|
+
the field you selected.
|
|
166
|
+
- Frontmatter and compiled values must be JSON-serializable; the build names
|
|
167
|
+
the offending value path otherwise.
|
|
168
|
+
|
|
169
|
+
Add types once: `"types": ["@pracht/markdown/client", "@pracht/content/virtual"]`
|
|
170
|
+
in tsconfig (or triple-slash references in any `.d.ts`).
|
|
171
|
+
|
|
172
|
+
## Step 7: Artifacts and the two llms.txt files
|
|
173
|
+
|
|
174
|
+
`llmsTxtArtifacts()` (collection-driven, curated, can emit an `llms-full.txt`)
|
|
175
|
+
is **not** the same as the core `llmsTxt` plugin option (app-graph driven,
|
|
176
|
+
`docs/LLMS_TXT.md`). Both default to `/llms.txt`, and enabling both fails the
|
|
177
|
+
build rather than silently overwriting — pick one, or give the collection a
|
|
178
|
+
distinct `summaryPath`. A third, unrelated file is `pracht llms`, which prints
|
|
179
|
+
the *authoring guide* for coding agents; `pracht llms --write` drops it in the
|
|
180
|
+
app root, which is exactly the confusing filename collision to avoid in an app
|
|
181
|
+
that publishes its own index.
|
|
182
|
+
|
|
183
|
+
Localization trap: a string `section.match` is compared against the
|
|
184
|
+
**locale-neutral** route, so `match: "/docs"` indexes only the default locale
|
|
185
|
+
while `rawContentArtifacts()` publishes every translation. Pass a `match`
|
|
186
|
+
function to index one locale deliberately.
|
|
187
|
+
|
|
188
|
+
Artifact paths are preflighted: canonical ASCII segments only, no overlap with
|
|
189
|
+
`publicDir`, bundle output, prerendered pages, request-time page/API paths,
|
|
190
|
+
concrete ISG paths, the `/_pracht` namespace, or Netlify's `/_headers` and
|
|
191
|
+
`/_redirects`.
|
|
192
|
+
|
|
193
|
+
## Step 8: Deployment notes
|
|
194
|
+
|
|
195
|
+
- **Cloudflare** — deferred content chunks require `"no_bundle": true` and an
|
|
196
|
+
`ESModule` rule covering `"**/*.js"` in `wrangler.jsonc`; `pracht verify`
|
|
197
|
+
warns when either is missing. New `create-pracht` projects have both.
|
|
198
|
+
- **Content type headers** — explicit artifact `contentType` values flow into
|
|
199
|
+
the production headers manifest and are applied by the Node, Cloudflare,
|
|
200
|
+
Netlify, and Vercel adapters.
|
|
201
|
+
- **Bundle cost** — payload chunks carry roughly two to three times the source
|
|
202
|
+
bytes. `snapshot: { raw: false }` or `{ body: false }` drops a representation
|
|
203
|
+
the app never reads; `compiled` and frontmatter cannot be dropped.
|
|
204
|
+
|
|
205
|
+
## Step 9: Optional agent surface
|
|
206
|
+
|
|
207
|
+
`@pracht/content/capabilities` exports `createContentPageCapability()` and
|
|
208
|
+
`createContentSearchCapability()`, which return `input`/`output`/`run` fields.
|
|
209
|
+
Keep the literal `defineCapability({ ... })` in the app so `pracht verify` can
|
|
210
|
+
audit exposure and policy statically — see `/add-capabilities`. Both helpers
|
|
211
|
+
read `document.body` and refuse a body-free snapshot when constructed.
|
|
212
|
+
|
|
213
|
+
## Step 10: Verify
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
pracht build # reconciliation warnings + artifact preflight
|
|
217
|
+
pracht verify --json
|
|
218
|
+
pracht typegen # after route ids or paths change
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Then load a content route in `pracht dev`, edit a source file, and confirm the
|
|
222
|
+
watcher invalidates it (collection roots outside Vite's project root are added
|
|
223
|
+
to the watcher explicitly).
|
|
224
|
+
|
|
225
|
+
## Rules
|
|
226
|
+
|
|
227
|
+
1. **Compiled Markdown is executed as HTML, unsanitized.** The generated route
|
|
228
|
+
module renders through `dangerouslySetInnerHTML`. Only compile
|
|
229
|
+
repo-authored, reviewed content; sanitize in `parse` or `render` (e.g.
|
|
230
|
+
`sanitize-html`) before compiling anything from a CMS, a database, or a
|
|
231
|
+
user.
|
|
232
|
+
2. Never import a capability-facing or loader-facing collection from client
|
|
233
|
+
code — import `virtual:pracht/content/<name>` inside server code only.
|
|
234
|
+
3. One `prachtContent()` call, with every collection in it.
|
|
235
|
+
4. Do not enable the core `llmsTxt` option and a collection `/llms.txt`
|
|
236
|
+
artifact at the same time.
|
|
237
|
+
5. Adding a collection must not publish raw source implicitly — add
|
|
238
|
+
`rawContentArtifacts()` only when publishing sources is intended.
|
|
239
|
+
6. Never overwrite an existing `vite.config.ts`, `wrangler.jsonc`, or content
|
|
240
|
+
module — diff first and confirm collisions with `AskUserQuestion`.
|
|
241
|
+
|
|
242
|
+
$ARGUMENTS
|