@pikku/skills 0.12.9 → 0.12.11

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +768 -0
  2. package/dist/skills.gen.js +1 -1
  3. package/package.json +4 -4
  4. package/skills/pikku-addon/SKILL.md +20 -14
  5. package/skills/pikku-addon/references/addon-package-manifest.md +2 -2
  6. package/skills/{pikku-ai-agent → pikku-agent}/SKILL.md +24 -24
  7. package/skills/pikku-ai-vercel/SKILL.md +18 -18
  8. package/skills/pikku-ai-voice/SKILL.md +15 -15
  9. package/skills/pikku-audit/SKILL.md +28 -13
  10. package/skills/pikku-aws/SKILL.md +2 -2
  11. package/skills/pikku-better-auth/SKILL.md +97 -17
  12. package/skills/pikku-build-app/SKILL.md +621 -0
  13. package/skills/pikku-build-app/references/multi-app.md +117 -0
  14. package/skills/pikku-build-app/references/ship.md +98 -0
  15. package/skills/pikku-build-app/references/theming.md +70 -0
  16. package/skills/pikku-build-platform/SKILL.md +239 -0
  17. package/skills/pikku-build-quick/SKILL.md +238 -0
  18. package/skills/pikku-cli/SKILL.md +7 -7
  19. package/skills/pikku-cli/references/complete-example.md +1 -1
  20. package/skills/pikku-concepts/SKILL.md +10 -7
  21. package/skills/pikku-concepts/references/concept-mapping.md +1 -1
  22. package/skills/pikku-config/SKILL.md +5 -3
  23. package/skills/pikku-deploy-azure/SKILL.md +5 -4
  24. package/skills/pikku-deploy-cloudflare/SKILL.md +9 -9
  25. package/skills/pikku-deploy-uws/SKILL.md +5 -2
  26. package/skills/pikku-deps/SKILL.md +42 -3
  27. package/skills/pikku-emails/SKILL.md +5 -5
  28. package/skills/pikku-fabric/SKILL.md +27 -3
  29. package/skills/pikku-fabric-debug/SKILL.md +1 -1
  30. package/skills/pikku-feature/SKILL.md +5 -4
  31. package/skills/pikku-http/SKILL.md +4 -4
  32. package/skills/pikku-http/references/http-options.md +13 -13
  33. package/skills/pikku-i18n/SKILL.md +2 -1
  34. package/skills/pikku-info/SKILL.md +1 -1
  35. package/skills/pikku-knowledge/SKILL.md +13 -13
  36. package/skills/pikku-kysely/SKILL.md +68 -41
  37. package/skills/pikku-machine-auth/SKILL.md +10 -10
  38. package/skills/pikku-mcp/SKILL.md +23 -20
  39. package/skills/pikku-middleware/SKILL.md +19 -12
  40. package/skills/pikku-middleware/references/middleware-patterns.md +14 -8
  41. package/skills/pikku-mongodb/SKILL.md +11 -11
  42. package/skills/pikku-n8n-import/SKILL.md +12 -12
  43. package/skills/pikku-n8n-import/SPEC.md +3 -0
  44. package/skills/pikku-n8n-import/references/addon-mapping.md +14 -8
  45. package/skills/pikku-n8n-import/references/code-translation.md +26 -22
  46. package/skills/pikku-n8n-import/references/loops-and-control.md +7 -7
  47. package/skills/pikku-paraglide/SKILL.md +11 -6
  48. package/skills/pikku-permissions/SKILL.md +19 -15
  49. package/skills/pikku-product-second-opinion/README.md +3 -3
  50. package/skills/pikku-product-second-opinion/SKILL.md +83 -73
  51. package/skills/pikku-product-second-opinion/example/sample-report.md +15 -12
  52. package/skills/pikku-product-second-opinion/references/report-template.md +10 -7
  53. package/skills/pikku-queue/SKILL.md +1 -1
  54. package/skills/pikku-react/SKILL.md +53 -13
  55. package/skills/pikku-realtime/SKILL.md +51 -19
  56. package/skills/pikku-rpc/SKILL.md +1 -1
  57. package/skills/pikku-rtl/SKILL.md +1 -1
  58. package/skills/pikku-scenario/SKILL.md +164 -44
  59. package/skills/pikku-schedule/SKILL.md +6 -1
  60. package/skills/pikku-schema-ajv/SKILL.md +2 -2
  61. package/skills/pikku-schema-cfworker/SKILL.md +1 -1
  62. package/skills/pikku-security/SKILL.md +9 -5
  63. package/skills/pikku-services/SKILL.md +27 -18
  64. package/skills/pikku-services/references/audit-wire-service.md +14 -8
  65. package/skills/pikku-software-archaeology/SKILL.md +27 -23
  66. package/skills/pikku-software-archaeology/references/blueprint.schema.json +580 -102
  67. package/skills/pikku-software-archaeology/scripts/validate.mjs +146 -69
  68. package/skills/pikku-tag-middleware/SKILL.md +1 -0
  69. package/skills/pikku-template-clone/SKILL.md +2 -1
  70. package/skills/pikku-trigger/SKILL.md +3 -3
  71. package/skills/pikku-versioning/SKILL.md +87 -3
  72. package/skills/pikku-websocket/SKILL.md +4 -3
  73. package/skills/pikku-workflow/SKILL.md +2 -2
  74. package/skills/pikku-workflow/references/workflow-reference.md +24 -10
  75. package/skills/pikku-ws/SKILL.md +5 -2
@@ -97,19 +97,38 @@ a one-word change, not a different import:
97
97
 
98
98
  ```ts
99
99
  export class PikkuRealtime {
100
- constructor(options?: { reconnect?: boolean; reconnectDelayMs?: number; reconnectMaxDelayMs?: number })
101
- setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor
100
+ constructor(options?: {
101
+ reconnect?: boolean
102
+ reconnectDelayMs?: number
103
+ reconnectMaxDelayMs?: number
104
+ })
105
+ setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor
102
106
 
103
107
  // WebSocket at /events — many topics on one connection
104
- subscribe<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): () => void
105
- unsubscribe<K extends keyof EventHubTopics>(topic: K, handler?: (data: EventHubTopics[K]) => void): void
108
+ subscribe<K extends keyof EventHubTopics>(
109
+ topic: K,
110
+ handler: (data: EventHubTopics[K]) => void
111
+ ): () => void
112
+ unsubscribe<K extends keyof EventHubTopics>(
113
+ topic: K,
114
+ handler?: (data: EventHubTopics[K]) => void
115
+ ): void
106
116
 
107
117
  // SSE at GET /events/:topic — one EventSource per topic
108
- subscribeToTopic<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): { close: () => void }
118
+ subscribeToTopic<K extends keyof EventHubTopics>(
119
+ topic: K,
120
+ handler: (data: EventHubTopics[K]) => void
121
+ ): { close: () => void }
109
122
 
110
123
  // generic escape hatches — see references/other-routes.md
111
- subscribeToSSE<T>(path: string, handler: (data: T) => void): { close: () => void }
112
- connectToChannel(channelRoute: string, protocols?: string | string[]): WebSocket
124
+ subscribeToSSE<T>(
125
+ path: string,
126
+ handler: (data: T) => void
127
+ ): { close: () => void }
128
+ connectToChannel(
129
+ channelRoute: string,
130
+ protocols?: string | string[]
131
+ ): WebSocket
113
132
 
114
133
  close(): void
115
134
  }
@@ -137,14 +156,16 @@ Envelope the payload as `{ topic, data }`: the generated client dispatches on th
137
156
  `topic` field, so a bare payload arrives but no handler fires.
138
157
 
139
158
  ```ts
140
- import { pikkuFunc } from '#pikku'
159
+ import { pikkuFunc } from '#pikku/function'
141
160
 
142
161
  export const createTodo = pikkuFunc({
143
162
  input: CreateTodoInput,
144
163
  output: CreateTodoOutput,
145
164
  func: async ({ kysely, eventHub }, data) => {
146
165
  const todo = await kysely
147
- .insertInto('todos').values(data).returningAll()
166
+ .insertInto('todos')
167
+ .values(data)
168
+ .returningAll()
148
169
  .executeTakeFirstOrThrow()
149
170
 
150
171
  await eventHub.publish('todo-created', null, {
@@ -160,7 +181,9 @@ A thin helper removes the duplication:
160
181
 
161
182
  ```ts
162
183
  async function publishEvent<K extends keyof EventHubTopics>(
163
- hub: EventHubService<EventHubTopics>, topic: K, data: EventHubTopics[K]
184
+ hub: EventHubService<EventHubTopics>,
185
+ topic: K,
186
+ data: EventHubTopics[K]
164
187
  ) {
165
188
  return hub.publish(topic, null, { topic, data })
166
189
  }
@@ -187,7 +210,9 @@ const pikku = createPikku(
187
210
  // pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch.
188
211
 
189
212
  createRoot(document.getElementById('root')!).render(
190
- <PikkuProvider pikku={pikku}><App /></PikkuProvider>
213
+ <PikkuProvider pikku={pikku}>
214
+ <App />
215
+ </PikkuProvider>
191
216
  )
192
217
  ```
193
218
 
@@ -214,7 +239,8 @@ function TodoList() {
214
239
  useEffect(() => {
215
240
  // WebSocket multi-topic:
216
241
  const off = realtime.subscribe('todo-created', ({ todo }) =>
217
- setTodos((prev) => [...prev, todo]))
242
+ setTodos((prev) => [...prev, todo])
243
+ )
218
244
  return off
219
245
 
220
246
  // Single-topic SSE (auto-cleanup on close) instead:
@@ -223,7 +249,13 @@ function TodoList() {
223
249
  // return () => sub.close()
224
250
  }, [realtime])
225
251
 
226
- return <ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
252
+ return (
253
+ <ul>
254
+ {todos.map((t) => (
255
+ <li key={t.id}>{t.title}</li>
256
+ ))}
257
+ </ul>
258
+ )
227
259
  }
228
260
  ```
229
261
 
@@ -235,12 +267,12 @@ sockets (`subscribeToSSE`, `connectToChannel`). See
235
267
 
236
268
  ## When to pick which transport
237
269
 
238
- | Need | Use |
239
- | ------------------------------------------ | ----------------------------- |
240
- | Many topics in one connection | `realtime.subscribe` |
241
- | Single live stream, simple cleanup | `realtime.subscribeToTopic` |
242
- | Bidirectional (client also sends messages) | `realtime.subscribe` |
243
- | WebSockets blocked by infra | `realtime.subscribeToTopic` |
270
+ | Need | Use |
271
+ | ------------------------------------------ | --------------------------- |
272
+ | Many topics in one connection | `realtime.subscribe` |
273
+ | Single live stream, simple cleanup | `realtime.subscribeToTopic` |
274
+ | Bidirectional (client also sends messages) | `realtime.subscribe` |
275
+ | WebSockets blocked by infra | `realtime.subscribeToTopic` |
244
276
 
245
277
  Both auto-clean on the server (the eventHub's `onChannelClosed` hook unsubscribes
246
278
  all topics for the dead channel id). Don't write manual cleanup unless you're
@@ -42,7 +42,7 @@ See `pikku-concepts` for the core mental model.
42
42
  | `rpc.remote(name, data)` | Remote call via DeploymentService |
43
43
  | `rpc.exposed(name, data)` | Call functions marked with `expose: true` |
44
44
  | `rpc.startWorkflow(name, input)` | Start a workflow (see `pikku-workflow`) |
45
- | `rpc.agent.run/stream(...)` | Run an AI agent (see `pikku-ai-agent`) |
45
+ | `rpc.agent.run/stream(...)` | Run an AI agent (see `pikku-agent`) |
46
46
  | `rpc.agent.resume/approve(...)` | Answer a tool-approval interrupt |
47
47
  | `rpc.agent.interrupt(runId)` | Stop an in-flight run |
48
48
 
@@ -18,7 +18,7 @@ Set `dir` **once at the document root** from the active locale, then let the
18
18
  browser and Mantine mirror everything — _provided_ every custom style is written
19
19
  **flow-relative** (start/end), never **physical** (left/right). Get those two
20
20
  things right and Arabic, Hebrew, Farsi and Urdu all work with zero per-component
21
- *layout* code — directional icons still need one manual step, covered below.
21
+ _layout_ code — directional icons still need one manual step, covered below.
22
22
 
23
23
  ## Agent Operating Procedure
24
24
 
@@ -46,7 +46,7 @@ Scenarios live in `srcDirectories` like any other function — by convention `*.
46
46
  `pikkuScenario` comes from the **generated** workflow types, not `@pikku/core`:
47
47
 
48
48
  ```typescript
49
- import { pikkuScenario } from '#pikku/workflow/pikku-workflow-types.gen.js'
49
+ import { pikkuScenario } from '#pikku/scenario'
50
50
 
51
51
  export const orderSupportScenario = pikkuScenario<
52
52
  { value?: number },
@@ -102,16 +102,23 @@ Prefer `expectEventually` over sleeping.
102
102
  ### Asserting on an agent's answer (`expectScore`)
103
103
 
104
104
  An agent's output is not comparable to a fixed string, so it is graded rather
105
- than matched. Declare the rubric with `pikkuAIScorer` (grades in code) or
106
- `pikkuAIJudge` (grades with a model) in a `*.scorer.ts` file, name it on the
105
+ than matched. Declare the rubric with `pikkuAgentScorer` (grades in code) or
106
+ `pikkuAgentJudge` (grades with a model) in a `*.scorer.ts` file, name it on the
107
107
  agent's `scorers`, then assert on the run the scenario just triggered:
108
108
 
109
109
  ```typescript
110
- const { runId } = await scenario.when('asks for a summary', 'runAssistant', {
111
- prompt: data.prompt,
112
- }, { actor: actors.user })
110
+ const { runId } = await scenario.when(
111
+ 'asks for a summary',
112
+ 'runAssistant',
113
+ {
114
+ prompt: data.prompt,
115
+ },
116
+ { actor: actors.user }
117
+ )
113
118
 
114
- await scenario.expectScore('answered briefly', runId, 'brevity', { atLeast: 0.8 })
119
+ await scenario.expectScore('answered briefly', runId, 'brevity', {
120
+ atLeast: 0.8,
121
+ })
115
122
  ```
116
123
 
117
124
  The default bound is `atLeast: 0.5`, so an unqualified `expectScore` still fails
@@ -168,7 +175,7 @@ Hooks are scenario-only. A `before`/`after` on a `pikkuWorkflowFunc` never runs
168
175
  `pikkuFeature` groups scenarios the way gherkin's `Feature:` groups `Scenario:`. Scenarios are referenced by **imported identifier**, so a renamed or deleted scenario is a compile error rather than a silent skip:
169
176
 
170
177
  ```typescript
171
- import { pikkuFeature } from '#pikku/workflow/pikku-workflow-types.gen.js'
178
+ import { pikkuFeature } from '#pikku/scenario'
172
179
  import {
173
180
  credentialLazyLoadScenario,
174
181
  credentialRoundTripScenario,
@@ -233,7 +240,7 @@ Utilities are **not steps**. They are plain exported functions, they take the br
233
240
 
234
241
  ```typescript
235
242
  // shop.browser.ts — shared actions. Not steps: nothing here is an intent.
236
- import type { PikkuBrowserWire } from '@pikku/core/workflow'
243
+ import type { PikkuBrowserWire } from '#pikku/scenario'
237
244
  import type {} from '@pikku/playwright'
238
245
 
239
246
  /** Arrive on the shop, from wherever the browser happens to be. */
@@ -370,6 +377,9 @@ export const seesTheOrderConfirmed = pikkuScenarioStep<
370
377
  { status: string }
371
378
  >({
372
379
  name: 'seesTheOrderConfirmed',
380
+ // Both bindings run as the persona, so the step declares one and the runner
381
+ // injects `wire.actor` — non-optional in every binding.
382
+ actor: true,
373
383
  template: 'sees order {orderId} confirmed',
374
384
  // Both run on `--run browser`. Each returns what it observed, and the runner
375
385
  // compares them — so this fails when the page disagrees with the database.
@@ -378,8 +388,9 @@ export const seesTheOrderConfirmed = pikkuScenarioStep<
378
388
  .locate({ testId: 'order-status', where: { 'data-order': orderId } })
379
389
  .getAttribute('data-status'),
380
390
  }),
381
- default: async ({ rpc }, { orderId }) => ({
382
- status: (await rpc.invoke('getOrder', { orderId })).status,
391
+ // Through the actor, not through a `rpc` service — see "What a step is given".
392
+ default: async (_services, { orderId }, { actor }) => ({
393
+ status: (await actor.invoke('getOrder', { orderId })).status,
383
394
  }),
384
395
  })
385
396
  ```
@@ -401,8 +412,7 @@ Assertions with no possible browser witness are a different thing and should not
401
412
  `scenario.do` can only name an RPC. A **step** is a named, typed unit of scenario behaviour whose body is an ordinary pikku function — so it can call several RPCs as its actor, assert, or drive a browser.
402
413
 
403
414
  ```typescript
404
- import { pikkuScenarioStep } from '#pikku/workflow/pikku-workflow-types.gen.js'
405
- import { requireActor } from '@pikku/core/workflow'
415
+ import { pikkuScenarioStep } from '#pikku/scenario'
406
416
 
407
417
  export const buysAnApple = pikkuScenarioStep<
408
418
  { qty: number },
@@ -411,8 +421,9 @@ export const buysAnApple = pikkuScenarioStep<
411
421
  name: 'buysAnApple',
412
422
  description: 'buys an apple',
413
423
  template: 'buys {qty} apples',
414
- default: async (_services, { qty }, { scenarioStep }) => {
415
- return await requireActor(scenarioStep).invoke('placeOrder', { qty })
424
+ actor: true,
425
+ default: async (_services, { qty }, { actor }) => {
426
+ return await actor.invoke('placeOrder', { qty })
416
427
  },
417
428
  })
418
429
  ```
@@ -436,12 +447,63 @@ Rules that bite:
436
447
  - **The step is referenced by its typed string name, not by importing the const** — exactly like `workflow.do`. The name is the step's `pikkuFuncId` and is checked against the generated step map. A non-literal target is a critical error (`PKU678`).
437
448
  - **Steps are not RPCs.** They are deliberately never network-callable — a browser-driving step must not be.
438
449
  - **`actor.invoke` is typed over the exposed RPC map**, so the name and the payload are checked and the result comes back narrowed — no cast. `actor.invokeRaw(name, data, { headers })` is the same call reporting `{ status, ok, body }` instead of throwing; use it whenever the refusal _is_ the assertion.
439
- - **`actor` and `env` are optional on the wire**, because a pure assertion step needs neither. Narrow them with `requireActor(scenarioStep)` and `requireScenarioEnv(scenarioStep)` from `@pikku/core/workflow` rather than a local guard — both name the step and say what to pass. `env` is `{ apiUrl, appUrl? }` from the environment the run targets, and is how a raw-HTTP step learns the target's URL: a step runs in the CLI process, where there is no `variables` service and `process.env` is not the answer.
450
+ - **A step that runs as somebody declares `actor: true`**, and the runner injects `wire.actor` — non-optional inside every binding, with no guard to write and nothing to unwrap. A `browser` binding implies it, because a window is opened as somebody. Leave it off for a step with no persona to be: an assertion over what an earlier step returned, or one that posts credentials precisely because it must not reuse an actor's session. Dispatching a step that declared it without `{ actor: actors.x }` fails before the body runs (`ScenarioActorRequired`); a step that did not declare it has no `actor` on its wire at all.
451
+ - **`env` is optional on the wire**, because most steps need nothing from it. Narrow it with `requireScenarioEnv(scenarioStep)` from `#pikku/scenario` rather than a local guard — it names the step and says what to pass. `env` is `{ apiUrl, appUrl? }` from the environment the run targets, and is how a raw-HTTP step learns the target's URL: a step runs in the CLI process, where there is no `variables` service and `process.env` is not the answer.
440
452
  - **Steps default to `retries: 0`**, unlike ordinary workflow steps. Retrying a failed assertion is wrong; pass `retries` explicitly if a step is genuinely flaky-by-nature.
441
453
  - **Step results are persisted**, so return JSON-serialisable data — never a `Locator` or a client object.
442
454
  - **`description` documents the step; `template` is what the report renders.** `template`'s `{placeholders}` are filled from the input the step was called with, so one step reads differently for each call — `sees {state} addon {packageName}` reports as "sees available addon @pikku/addon-stripe". Reflect every input field in the template, and type the values so they read as words (`state?: 'installed' | 'available'`, not `installed?: boolean`). A placeholder with no value renders as nothing and the whitespace collapses.
443
455
  - Prose precedence is `options.description` → the step's `template` → the step's own `description` → the positional step name. Repeated names get `#1`, `#2` ordinals, so a `for` loop over a data set is how you write a Scenario Outline. A loop-generated step name is not statically known, so it is matched back to its declaration by step function instead — which works as long as that function's call sites agree on their phase, actor and prose. Two call sites that disagree make the loop step report under its bare runtime name.
444
456
 
457
+ #### What a step is given
458
+
459
+ A step has the signature of an ordinary pikku function, which makes it look as
460
+ though it runs where the application runs. It does not — **it runs in the CLI
461
+ process**, and the services object is built there, by hand:
462
+
463
+ ```typescript
464
+ { logger, workflowService, workflowRunService, agentRunner? }
465
+ ```
466
+
467
+ That is the whole list. There is no `kysely`, no `variables`, no `secrets`, and
468
+ none of the project's own singleton or wire services. A step that destructures
469
+ one gets `undefined` and fails on first use — `Cannot read properties of
470
+ undefined (reading 'selectFrom')` — which reads like a broken container and is
471
+ not.
472
+
473
+ `rpc` is the trap worth naming, because it is present and it throws. It is a
474
+ `guardRpc` whose every member refuses:
475
+
476
+ > Scenario tried to run 'getOrder' as an internal step. Every workflow.do in a
477
+ > scenario must carry { actor: actors.x } so it executes against 'local'.
478
+
479
+ The same guard covers `rpc.agent.run/stream/resume/approve/interrupt` and
480
+ `startWorkflow`.
481
+
482
+ This is the design, not a gap: **everything a step touches of the application
483
+ goes over the wire as somebody.** A test that could reach into the database
484
+ would be testing a different program from the one a person uses. So there are
485
+ exactly three ways in, and they are all through the actor:
486
+
487
+ - `actor.invoke(name, data)` — typed over the exposed RPC map, carrying the
488
+ actor's session. Declare `actor: true` and destructure it off the wire.
489
+ - `.invokeRaw(name, data, { headers })` — same call, reporting
490
+ `{ status, ok, body }`, for when the refusal is the assertion.
491
+ - a plain `fetch` against `requireScenarioEnv(scenarioStep).apiUrl`, for
492
+ anything not an RPC — a websocket, a file upload, a webhook.
493
+
494
+ Two consequences follow, and both shape how steps get written:
495
+
496
+ - **A step cannot observe anything the app does not publish.** If a test needs a
497
+ fact the client never sees, the fix is to emit it on the stream or expose it
498
+ as an RPC — which usually improves the product, since a client debugging the
499
+ same problem needed it too.
500
+ - **`agentRunner` is conditional.** It is built only when the project declares
501
+ agents, and `createDevAgentRunner` needs a base URL *and* a key together
502
+ (`OPENAI_BASE_URL` + `OPENAI_API_KEY`, or the LiteLLM pair). With a key alone
503
+ it returns nothing and `agentRunner` is `undefined`, so `actor.converse`
504
+ fails before the persona says anything. A suite that would rather own its own
505
+ model can pass an `llm` to `runConversation` instead of relying on this one.
506
+
445
507
  ### Browser steps
446
508
 
447
509
  Declaring a `browser` binding is the whole switch: inside that binding `wire.browser` is guaranteed present and non-optional, and a step without one never sees a browser at all. There is nothing to null-check.
@@ -451,17 +513,20 @@ A `browser` binding gets a session bound to **its actor**, signed in through the
451
513
  Browser steps are where **intent, not actions** earns its keep: the step is one intent, the clicking lives in shared utilities, and the step arrives before it acts. Write the mechanics below into utilities and keep the step body to three or four calls that read as a sentence.
452
514
 
453
515
  ```typescript
454
- export const opensTheCart = pikkuScenarioStep<{ path: string }, { url: string }>(
455
- {
456
- name: 'opensTheCart',
457
- description: 'opens the cart',
458
- browser: async (_services, { path }, { browser }) => {
459
- await browser.goto(path)
460
- return { url: browser.page.url() }
461
- },
462
- default: async ({ rpc }) => ({ url: (await rpc.invoke('getCart', {})).url }),
463
- }
464
- )
516
+ export const opensTheCart = pikkuScenarioStep<
517
+ { path: string },
518
+ { url: string }
519
+ >({
520
+ name: 'opensTheCart',
521
+ description: 'opens the cart',
522
+ browser: async (_services, { path }, { browser }) => {
523
+ await browser.goto(path)
524
+ return { url: browser.page.url() }
525
+ },
526
+ default: async (_services, _data, { actor }) => ({
527
+ url: (await actor.invoke('getCart', {})).url,
528
+ }),
529
+ })
465
530
  ```
466
531
 
467
532
  - Install `@pikku/playwright` and `@playwright/test`, and import `@pikku/playwright` once (`import type {} from '@pikku/playwright'`) so `browser.page` is a typed Playwright `Page`. Without it you still get the structural `goto`/`screenshot` handle.
@@ -517,11 +582,68 @@ A persona holds only what is true of that kind of person for the app's whole lif
517
582
 
518
583
  `kind: "system"` is the app acting on its own — a schedule, a cleanup, a send. It gets **no actor**: there is nobody to sign in. Give it one by hand only if it genuinely has a service account.
519
584
 
585
+ #### Declaring personas in TypeScript
586
+
587
+ `definePersonas({ … })` is the code form of the block above, and there may be
588
+ **one call in the whole codebase** — one place to read the set from, one place
589
+ to add to it. A second anywhere, including in the same file, is a critical.
590
+ Generated files are exempt and never claim the slot.
591
+
592
+ > [!WARNING]
593
+ > The declaration is **read from source, never evaluated** — the CLI writes it
594
+ > to JSON that a deployed stage carries without the app. So every value has to
595
+ > be statically knowable, and a value that is not comes out as `undefined`
596
+ > rather than as an error. Only `name` is checked, so a computed `personality`,
597
+ > `jobTitle` or `description` is dropped in silence and the persona runs with a
598
+ > blank temperament.
599
+
600
+ What that admits and what it does not:
601
+
602
+ ```typescript
603
+ personality: 'Wound up and short with it.' // read
604
+ personality: `Wound up and short with it.
605
+ Says what she wants in a few blunt words.` // read — no ${} in it
606
+ personality: 'Wound up. ' + 'Short with it.' // dropped, silently
607
+ personality: TEMPERAMENTS.impatient // dropped, silently
608
+ ```
609
+
610
+ A no-substitution template literal is a string literal as far as the reader is
611
+ concerned, so it is the way to write a long personality across several lines —
612
+ not a concatenation, and not a `prettier-ignore`d single line. Its newlines and
613
+ leading indentation are kept verbatim and reach the model that way, which is
614
+ harmless but worth knowing before you align it to the surrounding code.
615
+
616
+ One more thing worth knowing before writing a rich persona: **`actor.converse`
617
+ builds its prompt from `name`, `jobTitle`, `personality` and the scenario's
618
+ `task` only.** Fields like `disposition`, `goals` and `roles` are read and
619
+ stored, and the console shows them, but they do not reach the conversing
620
+ persona's instructions. Anything that must shape how someone talks belongs in
621
+ `personality` or in the task.
622
+
520
623
  An actor with no `persona` is its own persona, so a project that never declares any keeps working unchanged.
521
624
 
522
625
  - `environments.<name>.apiUrl` is required. `signInPath` defaults to `/auth/sign-in/actor`, `rpcPath` to `/rpc`.
523
626
  - **`SCENARIO_ACTOR_SECRET` is an environment variable and never goes in `pikku.config.json`.** It signs actors in. `pikku scenario run` throws without it; a server auto-building actors warns and runs without them.
524
627
 
628
+ ### The same actors sign a human in
629
+
630
+ Declared actors are not only for automated runs. `signInPath` is Better Auth's
631
+ `actor` plugin (see `pikku-better-auth`), which any caller can post to — so the
632
+ frontend gets a one-click "Sign in as …" switcher over the **same** list, and an
633
+ app can be reviewed as each kind of user without anyone knowing a seed password.
634
+
635
+ The sandbox dev server bakes both halves into the frontend from the declared
636
+ personas: `VITE_DEV_ACTORS` (the JSON actor list) and
637
+ `VITE_SCENARIO_ACTOR_SECRET`. Neither is set in a production build, so the
638
+ control renders nothing there — but gate the reads on your bundler's dev flag
639
+ anyway (`import.meta.env.DEV ? … : undefined`) so the secret never reaches a
640
+ production bundle in the first place.
641
+
642
+ Do not hand-roll the switcher: `useDevActors()` (`pikku-react`) is the logic and
643
+ `<DevActorSwitcher />` from `@pikku/mantine/dev` is a ready rendering of it.
644
+ `pikku fabric validate` **requires** any frontend with a login screen to ship
645
+ one — without it a reviewer is locked out of their own sandbox.
646
+
525
647
  ## Running
526
648
 
527
649
  ```bash
@@ -535,19 +657,19 @@ SCENARIO_ACTOR_SECRET=… pikku scenario run local --spawn --no-browser --exclud
535
657
 
536
658
  `run` takes the environment as a **required positional** — the key from `environments`. Every filter narrows the same plan, so narrowing a feature to two of its five scenarios still runs the feature's hooks exactly once around those two.
537
659
 
538
- | Flag | Effect |
539
- | ----------------------- | --------------------------------------------------------------------------------------- |
540
- | `--flows` / `-f` | Comma-separated scenario names |
541
- | `--features` | Comma-separated feature ids |
542
- | `--tags` / `-t` | Match-any tag filter |
543
- | `--exclude-tags` | Hold tags back — unless the flow is named directly with `--flows` |
544
- | `--run <surface>` | `default` (the default), `browser`, or `cli` |
545
- | `--no-browser` | Shorthand for `--run default`; scenarios with browser steps report as **skipped** |
546
- | `--strict` | Fail, rather than pass, a `then` with no witness on the run's surface |
547
- | `--spawn` / `--keep-alive` | Start `pikku dev` on the environment's apiUrl for the run; optionally leave it up |
548
- | `--api-url` / `--app-url` | Override the environment's URLs — for a target that only exists at run time |
549
- | `--trace` | Keep every stack frame on failure (default shows only the project's own) |
550
- | `--coverage` | Reset/snapshot server coverage per scenario |
660
+ | Flag | Effect |
661
+ | -------------------------- | --------------------------------------------------------------------------------- |
662
+ | `--flows` / `-f` | Comma-separated scenario names |
663
+ | `--features` | Comma-separated feature ids |
664
+ | `--tags` / `-t` | Match-any tag filter |
665
+ | `--exclude-tags` | Hold tags back — unless the flow is named directly with `--flows` |
666
+ | `--run <surface>` | `default` (the default), `browser`, or `cli` |
667
+ | `--no-browser` | Shorthand for `--run default`; scenarios with browser steps report as **skipped** |
668
+ | `--strict` | Fail, rather than pass, a `then` with no witness on the run's surface |
669
+ | `--spawn` / `--keep-alive` | Start `pikku dev` on the environment's apiUrl for the run; optionally leave it up |
670
+ | `--api-url` / `--app-url` | Override the environment's URLs — for a target that only exists at run time |
671
+ | `--trace` | Keep every stack frame on failure (default shows only the project's own) |
672
+ | `--coverage` | Reset/snapshot server coverage per scenario |
551
673
 
552
674
  Output is `PASS <name> (<ms>) → <output>` / `FAIL <name> (<ms>): <error>`, then `N/M scenarios passed against '<env>'`. A scenario inside a feature is named `<Feature> › <scenario> <data>`.
553
675
 
@@ -584,9 +706,7 @@ The run resets coverage before each scenario and snapshots after, writing **`<ou
584
706
  "generatedAt": "…",
585
707
  "environment": "local",
586
708
  "scenarios": {
587
- "<name>": {
588
- /* FunctionCoverageReport */
589
- },
709
+ "<name>": {/* FunctionCoverageReport */},
590
710
  },
591
711
  }
592
712
  ```
@@ -640,7 +760,7 @@ Services are plain objects — a Pikku function is pure business logic, so a moc
640
760
  | A step named `clicksAddToBasket` / `opensThePage` | That is an action, not an intent. Name the step for what the actor wanted; put the clicking in a utility. |
641
761
  | A browser step that assumes it is already on a page | It can then only run mid-flow. Arrive first — check the URL, navigate if needed. |
642
762
  | A `browser` binding guarding `if (!browser)` | The binding guarantees it. The guard hides the real error, which is a missing actor (`PKU677`). |
643
- | A step with a `func:` instead of a surface binding | There is no `func` on a step. Bodies live under `default` / `browser` / `cli`; a step with none throws at load. |
763
+ | A step with a `func:` instead of a surface binding | There is no `func` on a step. Bodies live under `default` / `browser` / `cli`; a step with none throws at load. |
644
764
  | `expectEventually` in a `pikkuWorkflowFunc` | `PKU675` — scenario-only. |
645
765
  | Coverage silently 0 | Server not run with `--coverage`, verbose functions meta not deployed, `scaffold.scenarios` unset, or no actors configured. |
646
766
 
@@ -51,7 +51,12 @@ development and a single-instance deployment, wrong for anything else.
51
51
  ### Scheduling a one-off RPC
52
52
 
53
53
  ```typescript
54
- const taskId = await schedulerService.scheduleRPC('5m', 'sendReminder', data, session)
54
+ const taskId = await schedulerService.scheduleRPC(
55
+ '5m',
56
+ 'sendReminder',
57
+ data,
58
+ session
59
+ )
55
60
  await schedulerService.getTask(taskId) // { rpcName, scheduledFor, status, … } | null
56
61
  await schedulerService.getAllTasks() // pending one-offs only
57
62
  await schedulerService.unschedule(taskId) // true when it was still pending
@@ -52,7 +52,7 @@ called `schema` in the source, which reads backwards.
52
52
 
53
53
  - **Registration is name-keyed and never re-compiles.** A second
54
54
  `compileSchema('X', …)` with a different schema is a no-op; the first one wins
55
- for the process lifetime. `@pikku/schema-cfworker` *does* recompile on a
55
+ for the process lifetime. `@pikku/schema-cfworker` _does_ recompile on a
56
56
  changed value, so a dev hot-reload after codegen picks up a changed schema
57
57
  there but not here — restart the process instead.
58
58
  - **AJV is a module-level singleton**, shared by every `AjvSchemaService` you
@@ -62,7 +62,7 @@ called `schema` in the source, which reads backwards.
62
62
  become `1` — the wiring layer is what coerces, not this service.
63
63
  - `ajv-formats` is registered, so `format` keywords (`email`, `uuid`, `date-time`)
64
64
  are enforced.
65
- - A failed validation throws `UnprocessableContentError` (a 422). A *missing*
65
+ - A failed validation throws `UnprocessableContentError` (a 422). A _missing_
66
66
  schema throws a bare string, `Missing validator for <name>` — not an `Error`,
67
67
  so `catch (e) { e.message }` reads `undefined`. That normally means codegen
68
68
  didn't run.
@@ -63,7 +63,7 @@ surface as behaviour changes rather than compile errors:
63
63
  underlying cause swallowed — check the schema by hand when you see it.
64
64
 
65
65
  A failed validation throws `UnprocessableContentError` (422) with the validator
66
- errors joined; a *missing* schema throws a bare string, `Missing validator for
66
+ errors joined; a _missing_ schema throws a bare string, `Missing validator for
67
67
  <name>`, not an `Error`.
68
68
 
69
69
  ## Usage Patterns
@@ -62,7 +62,7 @@ Apply these via `addHTTPMiddleware` in a wirings file:
62
62
 
63
63
  ```typescript
64
64
  import { authBearer, authCookie, authAPIKey } from '@pikku/core/middleware'
65
- import { addHTTPMiddleware } from '#pikku'
65
+ import { addHTTPMiddleware } from '#pikku/http'
66
66
 
67
67
  // JWT bearer token — reads Authorization header
68
68
  addHTTPMiddleware('*', [authBearer()])
@@ -118,14 +118,18 @@ response.
118
118
 
119
119
  ```typescript
120
120
  // permissions.ts
121
- import { pikkuAuth, pikkuPermission } from '#pikku'
121
+ import { pikkuAuth, pikkuPermission } from '#pikku/function'
122
122
 
123
- export const isAuthenticated = pikkuAuth(async (_services, session) => !!session)
124
- export const isVerified = pikkuAuth(async (_services, session) => !!session?.emailVerified)
123
+ export const isAuthenticated = pikkuAuth(
124
+ async (_services, session) => !!session
125
+ )
126
+ export const isVerified = pikkuAuth(
127
+ async (_services, session) => !!session?.emailVerified
128
+ )
125
129
 
126
130
  // wirings/auth.wiring.ts
127
131
  import { authCookie } from '@pikku/core/middleware'
128
- import { addHTTPMiddleware } from '#pikku'
132
+ import { addHTTPMiddleware } from '#pikku/http'
129
133
 
130
134
  addHTTPMiddleware('*', [
131
135
  authCookie({
@@ -38,7 +38,7 @@ pikku info tags --verbose # Understand project organization
38
38
  ### `pikkuServices(factory)` — singleton services (created once at startup)
39
39
 
40
40
  ```typescript
41
- import { pikkuServices } from '#pikku'
41
+ import { pikkuServices } from '#pikku/function'
42
42
  import { ConsoleLogger } from '@pikku/core/services'
43
43
  import { JoseJWTService } from '@pikku/jose'
44
44
 
@@ -61,7 +61,7 @@ export const createSingletonServices = pikkuServices(
61
61
  ### `pikkuWireServices(factory)` — per-request services (fresh per HTTP request, queue job, CLI command, etc.)
62
62
 
63
63
  ```typescript
64
- import { pikkuWireServices } from '#pikku'
64
+ import { pikkuWireServices } from '#pikku/function'
65
65
 
66
66
  export const createWireServices = pikkuWireServices(
67
67
  async (singletonServices, wire) => {
@@ -157,13 +157,16 @@ const getUser = pikkuFunc({
157
157
 
158
158
  **Never write a `if (!service) throw ...` existence guard in a function body.** It is dead code, and it defeats the platform.
159
159
 
160
- Optionality lives in exactly one place — `services.ts` / the `SingletonServices` declaration — and it means *"this may not be created"*, not *"this may be missing at call time"*. A service is optional precisely because **nothing destructures it**, and the generated `requiredSingletonServices` manifest therefore never marks it for creation. The moment any wired function destructures it, Pikku creates it and guarantees it is there.
160
+ Optionality lives in exactly one place — `services.ts` / the `SingletonServices` declaration — and it means _"this may not be created"_, not _"this may be missing at call time"_. A service is optional precisely because **nothing destructures it**, and the generated `requiredSingletonServices` manifest therefore never marks it for creation. The moment any wired function destructures it, Pikku creates it and guarantees it is there.
161
161
 
162
162
  The types enforce this rather than merely documenting it. The inspector records the services destructured by every wired `func`, `permissions` **and** `middleware`, and emits them as `RequiredSingletonServices`. The generated function types then default their service parameter to:
163
163
 
164
164
  ```typescript
165
- export type WiredSingletonServices = RequiredSingletonServices & SingletonServices
166
- export type WiredServices = SecretlessServices<RequiredSingletonServices & Services>
165
+ export type WiredSingletonServices = RequiredSingletonServices &
166
+ SingletonServices
167
+ export type WiredServices = SecretlessServices<
168
+ RequiredSingletonServices & Services
169
+ >
167
170
  ```
168
171
 
169
172
  The `SecretlessServices<...>` wrapper is why `secrets` never appears in a
@@ -223,21 +226,21 @@ const createSingletonServices = pikkuServices(async (config) => {
223
226
 
224
227
  ### Built-in Services
225
228
 
226
- | Service | Package | Purpose |
227
- | -------------------------- | ---------------------- | -------------------------------- |
228
- | `ConsoleLogger` | `@pikku/core/services` | Console-based logging |
229
- | `JoseJWTService` | `@pikku/jose` | JWT sign/verify via jose |
230
- | `LocalSecretService` | `@pikku/core/services` | Local development secrets |
231
- | `LocalVariablesService` | `@pikku/core/services` | Local environment variables |
232
- | `PinoLogger` | `@pikku/pino` | Structured logging via Pino |
233
- | `createInvocationAudit` | `@pikku/core/services` | Per-request audit buffer |
234
- | `createAuditedKysely` | `@pikku/kysely` | Auto-capture DB queries as audit events |
229
+ | Service | Package | Purpose |
230
+ | ----------------------- | ---------------------- | --------------------------------------- |
231
+ | `ConsoleLogger` | `@pikku/core/services` | Console-based logging |
232
+ | `JoseJWTService` | `@pikku/jose` | JWT sign/verify via jose |
233
+ | `LocalSecretService` | `@pikku/core/services` | Local development secrets |
234
+ | `LocalVariablesService` | `@pikku/core/services` | Local environment variables |
235
+ | `PinoLogger` | `@pikku/pino` | Structured logging via Pino |
236
+ | `createInvocationAudit` | `@pikku/core/services` | Per-request audit buffer |
237
+ | `createAuditedKysely` | `@pikku/kysely` | Auto-capture DB queries as audit events |
235
238
 
236
239
  ## Complete Example
237
240
 
238
241
  ```typescript
239
242
  // services.ts
240
- import { pikkuServices, pikkuWireServices } from '#pikku'
243
+ import { pikkuServices, pikkuWireServices } from '#pikku/function'
241
244
  import { ConsoleLogger } from '@pikku/core/services'
242
245
  import { JoseJWTService } from '@pikku/jose'
243
246
 
@@ -249,9 +252,15 @@ class TodoStore {
249
252
  this.todos.set(todo.id, todo)
250
253
  return todo
251
254
  }
252
- async get(id: string) { return this.todos.get(id) }
253
- async list() { return [...this.todos.values()] }
254
- async delete(id: string) { this.todos.delete(id) }
255
+ async get(id: string) {
256
+ return this.todos.get(id)
257
+ }
258
+ async list() {
259
+ return [...this.todos.values()]
260
+ }
261
+ async delete(id: string) {
262
+ this.todos.delete(id)
263
+ }
255
264
  }
256
265
 
257
266
  export const createSingletonServices = pikkuServices(async (config) => {