@ekanos/cli 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@ekanos/cli",
3
- "version": "0.3.0",
4
- "private": false,
3
+ "version": "0.3.1",
5
4
  "type": "module",
6
5
  "description": "The Ekanos partner toolchain CLI: scaffold, validate, and test a Fusion integration against the published @ekanos packages. Agent-native — every verb speaks JSON with a stable exit-code taxonomy.",
7
6
  "license": "MIT",
@@ -34,7 +33,7 @@
34
33
  },
35
34
  "dependencies": {
36
35
  "@clack/prompts": "1.7.0",
37
- "@ekanos/integration-schema": "0.2.0",
36
+ "@ekanos/integration-schema": "0.3.0",
38
37
  "esbuild": "0.28.1",
39
38
  "picocolors": "1.1.1"
40
39
  },
@@ -27,6 +27,7 @@ export const integration = defineIntegration<MyStorage>({
27
27
  permissions: [ /* disclosure of what you read/write */ ],
28
28
  components: { widgets: [ /* … */ ] },
29
29
  tools: [ /* assistant-callable functions */ ],
30
+ actions: { /* deterministic, widget-invoked server handlers */ },
30
31
  webhooks: [ /* inbound deliveries */ ],
31
32
  schedules: [ /* cron runs */ ],
32
33
  onActivate: async (ctx) => { /* seed caches, sanity-check credentials */ },
@@ -172,6 +173,76 @@ failure). Schedule results: `{ status: 'completed' }` / `{ status: 'skipped' }`
172
173
  (`invocation.trigger` distinguishes them). Cron is 5-field numeric only — no
173
174
  names, no `@daily`, no seconds.
174
175
 
176
+ ### Actions
177
+
178
+ `actions` is a record — keyed by kebab-case action name (`'create-record'`,
179
+ not `tools`' `snake_case`) — of deterministic, widget-invoked server
180
+ handlers. It is a **parallel surface to `tools`, not a flag on it**: a
181
+ widget's Save button already has fully-typed, user-entered values, so it
182
+ calls your handler directly instead of routing a natural-language prompt
183
+ through the assistant and hoping the model picks the right tool with the
184
+ right arguments.
185
+
186
+ ```ts
187
+ actions: {
188
+ 'create-record': {
189
+ description: 'Create a record in the base.',
190
+ input: CreateRecordInput, // a real zod schema
191
+ output: CreateRecordOutput, // optional — validates what handler resolves to
192
+ handler: async (ctx, input) => {
193
+ // Same ctx every tool/webhook/schedule handler gets: ctx.fetch,
194
+ // ctx.secrets, ctx.storage — runs server-side, never in the browser.
195
+ return createRecordViaApi(ctx, input);
196
+ },
197
+ },
198
+ },
199
+ ```
200
+
201
+ `input` (and the optional `output`) are real `z.ZodTypeAny` instances, not
202
+ the JSON-schema-shaped `parameters` a tool declares for LLM argument
203
+ synthesis — an action is only ever parsed in-process, never serialized to a
204
+ model. Export the inferred types alongside the declaration
205
+ (`export type CreateRecordInput = z.infer<typeof CreateRecordInput>`) so the
206
+ widget importing them gets real autocomplete, per the SDK typing note below.
207
+
208
+ **Invoke it from a widget with `useIntegrationAction()`** (`@ekanos/sdk/hooks`)
209
+ — never `useAskAssistant()` for this. `useAskAssistant()` sends a
210
+ conversational question to the assistant dock (`Widget.AskFooter`'s "ask
211
+ anything about this widget"); it is not a write path and does not call your
212
+ handler deterministically. A form's Save button calls the handler you
213
+ declared, with the exact values the user typed:
214
+
215
+ ```tsx
216
+ const createRecord = useIntegrationAction<CreateRecordInput, CreateRecordOutput>(
217
+ '__SLUG__',
218
+ 'create-record',
219
+ { accountId },
220
+ );
221
+
222
+ <Button onClick={() => createRecord.mutate(formValues)} disabled={createRecord.isPending}>
223
+ Save
224
+ </Button>
225
+ ```
226
+
227
+ A rejected mutation's `error` is always an `IntegrationActionError`
228
+ (`status`, `code`, and — for a 400 — your own declared zod `issues`), never a
229
+ raw fetch exception. The route answers a fixed set of `code`s your widget can
230
+ branch on without string-matching: `action_not_found`, `invalid_input`,
231
+ `rate_limited`, `handler_error` (plus the client-assigned `network_error` /
232
+ `invalid_response` for a request that never reached a valid envelope).
233
+
234
+ A handler that wants its OWN message shown to the end user verbatim (e.g.
235
+ "That record already exists") throws `PublicActionError`
236
+ (`@ekanos/sdk/integration`) — the one escape hatch from the route's default
237
+ opaque `handler_error` message. Every other throw collapses to a generic
238
+ "Action failed." on the wire, though the real error is always logged
239
+ server-side.
240
+
241
+ Reuse one handler as both an action and a tool without double-declaring
242
+ logic — export the handler once and reference it from both declarations
243
+ (`run: (ctx, args) => createRecord.handler(ctx, CreateRecordInput.parse(args))`)
244
+ if you also want the assistant able to call it.
245
+
175
246
  ### `onActivate`
176
247
 
177
248
  Runs once after an activation is first persisted, and again after
@@ -222,6 +293,12 @@ harness and in production. A widget reads declared `clientReadable`
222
293
  account-scope storage back with `fetchIntegrationStorage()` from
223
294
  `@ekanos/sdk/hooks`.
224
295
 
296
+ For a widget that WRITES — a form's Save button, not a read — reach for
297
+ `useIntegrationAction()` (see "Actions" above), not `useAskAssistant()`.
298
+ `useAskAssistant()` sends a conversational question to the assistant dock;
299
+ it is not a deterministic write path and gives no guarantee the model calls
300
+ your handler with the values the user actually typed.
301
+
225
302
  Build widget UI from `@ekanos/ui` — the published slice of the host design
226
303
  system. Available modules (import individually, e.g. `@ekanos/ui/button`):
227
304
  `alert`, `badge`, `button`, `card`, `dialog`, `dropdown-menu`, `form`, `icon`,
@@ -307,14 +384,18 @@ what the live context is for.
307
384
 
308
385
  Two different fetches, two different guarantees:
309
386
 
310
- - **`ctx.fetch` (handlers — `onActivate`/webhooks/schedules/tools).** Real
311
- enforcement, in the harness AND in production: your `egress` allowlist,
312
- redirect re-vetting, IP pinning. Locally it depends on the toolbar: in
313
- fixtures mode handlers run in the browser against the mock context and
314
- `ctx.fetch` answers from your fixtures, never the network; in live mode
315
- they run on the dev server's live context and `ctx.fetch` reaches the real
316
- vendor, egress-checked first — which is how the OAuth code exchange and a
317
- post-connect sync work locally at all.
387
+ - **`ctx.fetch` (handlers — `onActivate`/webhooks/schedules/tools/actions).**
388
+ Real enforcement, in the harness AND in production: your `egress`
389
+ allowlist, redirect re-vetting, IP pinning. Locally it depends on the
390
+ toolbar: in fixtures mode handlers run in the browser against the mock
391
+ context and `ctx.fetch` answers from your fixtures, never the network; in
392
+ live mode they run on the dev server's live context and `ctx.fetch` reaches
393
+ the real vendor, egress-checked first — which is how the OAuth code
394
+ exchange and a post-connect sync work locally at all. An `actions[name]`
395
+ handler is executed exactly the same way as a tool or webhook handler — a
396
+ widget's `useIntegrationAction()` call never reaches the vendor directly;
397
+ it always crosses through your server-side handler and `ctx.fetch` first,
398
+ in the harness and in production alike.
318
399
  - **`useIntegrationFetch()` (widgets, browser-side).** In the harness, live
319
400
  mode routes it through the same egress check as a convenience, enforced
320
401
  centrally rather than something you configure per widget. **That check
@@ -328,7 +409,14 @@ Two different fetches, two different guarantees:
328
409
  In short: widgets get real network in Live API mode, gated by convenience in
329
410
  the harness and by nothing in production; handlers get fixtures in fixtures
330
411
  mode and the real, enforced thing in live mode — locally on the dev server,
331
- then on the host after publish.
412
+ then on the host after publish. `actions` handlers follow the handler rule:
413
+ `useIntegrationAction()` in the harness runs your handler against fixtures
414
+ by default, same as `invokeWebhook`/`invokeSchedule` — live execution is an
415
+ explicit, per-session opt-in from the dev toolbar (a confirmation the first
416
+ time a call would touch the real vendor that session, not a per-call
417
+ prompt), never the default. In production there is only one mode: the
418
+ handler always runs server-side with the real `IntegrationContext` and real
419
+ `ctx.fetch` egress enforcement.
332
420
 
333
421
  ## Testing
334
422
 
@@ -15,8 +15,8 @@
15
15
  "@ekanos/ui": "^0.2.0"
16
16
  },
17
17
  "devDependencies": {
18
- "@ekanos/cli": "^0.2.2",
19
- "@ekanos/harness": "^0.1.5",
18
+ "@ekanos/cli": "^0.3.0",
19
+ "@ekanos/harness": "^0.2.0",
20
20
  "@hookform/resolvers": "^5.2.2",
21
21
  "@tailwindcss/postcss": "^4.3.3",
22
22
  "@tanstack/react-query": "^5.101.4",
@@ -52,8 +52,10 @@ otherwise ask in plain prose with lettered options.
52
52
  3. **The outcomes** (free text): "What should a team see on their dashboard,
53
53
  and what should the integration be able to do?" Push for 1–3 concrete
54
54
  outcomes ("show open invoices", "alert on new disputes", "let the
55
- assistant create a customer"). Each SEE-outcome becomes a widget; each
56
- DO-outcome becomes a tool, webhook, or schedule.
55
+ assistant create a customer", "let someone create a record from a
56
+ dashboard form"). Each SEE-outcome becomes a widget; each DO-outcome
57
+ becomes a tool (assistant-invoked), a webhook/schedule (event-driven), or
58
+ an action (a widget's own deterministic write — a form's Save button).
57
59
  4. **Auth** (choice): API key / Bearer / OAuth 2.0 / Basic / not sure
58
60
  (research will settle it). This decides the activation-form fields — and
59
61
  every string field name on that form IS a secret name (see AGENTS.md).
@@ -134,7 +136,9 @@ notes, update `docs/api-notes.md` in the same commit as the code fix.
134
136
 
135
137
  Order: activation form (field names = secret names, keep them in exported
136
138
  constants) → server handlers using `ctx.fetch` / `ctx.storage` /
137
- `ctx.secrets` only → tools → webhooks/schedules. For an OAuth integration,
139
+ `ctx.secrets` only → tools → webhooks/schedules → actions (deterministic
140
+ widget writes — a form's Save button, invoked with `useIntegrationAction()`,
141
+ never routed through the assistant). For an OAuth integration,
138
142
  also read `references/oauth-setup.md`: it tells you what to put on the
139
143
  harness entry so the harness walks the user through their provider's console,
140
144
  and how to hand the user off to the Local OAuth panel — the user's secrets
@@ -30,6 +30,15 @@ code. This file is the order of work and the traps.
30
30
  `fetchIntegrationStorage()` from `@ekanos/sdk/hooks`. Cache freshness in
31
31
  the stored value (`fetchedAt`) and show it in the UI when staleness
32
32
  matters.
33
+ 6. **Widget writes** — the read path's mirror. A form's Save button declares
34
+ an `actions[name]` handler (zod `input`, optional `output`, `handler(ctx,
35
+ input)`) and calls it with `useIntegrationAction()` from
36
+ `@ekanos/sdk/hooks` — deterministic, with the values the user actually
37
+ typed, never a natural-language hand-off to the assistant. A handler that
38
+ wants its own message shown to the user verbatim throws
39
+ `PublicActionError` from `@ekanos/sdk/integration`; every other throw
40
+ collapses to a generic error on the wire (still logged in full
41
+ server-side). See "Actions" in AGENTS.md.
33
42
 
34
43
  ## Traps, each one earned
35
44
 
@@ -94,4 +103,8 @@ on its `harness.config.ts` entry — see AGENTS.md "Live mode") and the
94
103
  toolbar's fixtures ⇄ live switch runs real widget queries against the real
95
104
  API, gated only by the declared `egress`. Use fixtures for the Fusion-side
96
105
  state widgets need first, and for webhook/schedule handlers (the Triggers
97
- surface runs those against the mock context by design).
106
+ surface runs those against the mock context by design). `actions[name]`
107
+ handlers follow the same rule: `useIntegrationAction()` calls run against
108
+ fixtures by default, and only reach the real vendor after an explicit,
109
+ per-session live-mode confirmation from the toolbar — never on the first
110
+ call by accident.
@@ -35,6 +35,14 @@ name the question for is a widget you cannot design.
35
35
  | Chart | "Which way is it going?" | The chart + current value; axes and legends muted |
36
36
  | Action / connection | "What can I do / who's connected?" | One clear CTA or the identity + status |
37
37
 
38
+ A widget whose CTA WRITES something (a form, a "Create record" button) calls
39
+ a declared `actions[name]` handler via `useIntegrationAction()` — a
40
+ deterministic RPC with the values the user actually typed, not a
41
+ conversational hand-off. Reach for `useAskAssistant()` only for an actual
42
+ "ask about this" affordance (`Widget.AskFooter`); it is not a write path and
43
+ gives no guarantee the assistant calls anything with the right arguments.
44
+ See "Actions" in `AGENTS.md`.
45
+
38
46
  The default failure is uniform weight: everything `text-sm`, values dressed
39
47
  like labels. Squint at the render — if nothing reads first, there is no
40
48
  hierarchy, and no amount of padding will save it.
@@ -74,8 +82,10 @@ Every widget: `'use client'`, `Widget.DataState` wrapping `Widget.Card` →
74
82
  fetching on `ctx?.state.state === 'active'` from `WidgetContext`, and fetch
75
83
  with `useIntegrationFetch()` from `@ekanos/sdk/hooks` — never global
76
84
  `fetch` — reaching for `useActivationData()` (same module) if the widget
77
- needs a client-visible credential. Copy the canonical example from the SDK
78
- README rather than improvising the tree.
85
+ needs a client-visible credential. For a write (a form submit), call
86
+ `useIntegrationAction()` (same module) instead — it posts to your declared
87
+ `actions[name]` handler, not the vendor directly. Copy the canonical example
88
+ from the SDK README rather than improvising the tree.
79
89
 
80
90
  `Widget.Active` itself renders flush (no padding) so full-bleed charts and
81
91
  tables can run edge-to-edge. For ordinary content — text, rows, a KPI
@@ -80,4 +80,20 @@ export const integration = defineIntegration({
80
80
  },
81
81
  },
82
82
  ],
83
+
84
+ // `actions` is the deterministic counterpart to `tools`: a widget's own
85
+ // Save button calls a declared handler directly (via `useIntegrationAction()`
86
+ // from `@ekanos/sdk/hooks`), with the exact values the user typed — never a
87
+ // natural-language hand-off to the assistant. Uncomment and adapt once a
88
+ // widget needs to write something; see "Actions" in AGENTS.md.
89
+ //
90
+ // actions: {
91
+ // 'create-record': {
92
+ // description: 'Create a record in the base.',
93
+ // input: z.object({ name: z.string().min(1) }),
94
+ // handler: async (_ctx, input) => {
95
+ // return { id: 'rec_123', name: input.name };
96
+ // },
97
+ // },
98
+ // },
83
99
  });