@uidu/skills 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/skills/uidu/SKILL.md +187 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uidu/skills",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Agent skills for the uidu SDK — installable via the open agent skills ecosystem (skills.sh).",
5
5
  "license": "MIT",
6
6
  "author": "uidu",
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: uidu
3
- description: Use when building on the uidu platform — a public website or an app that runs inside uidu (a custom app in a Space or workspace); reading CMS pages, events, stories, donations, help center, forms; rendering WYSIWYG content; scaffolding projects; OR provisioning/authoring uidu content (create/update/delete) from the terminal via the @uidu/cli (`uidu`). Triggers on any task involving uidu, the @uidu/client SDK, @uidu/react bindings, or the uidu CLI.
3
+ description: Use when building on the uidu platform — a public website or an app that runs inside uidu (a custom app in a Space or workspace); reading CMS pages, events, stories, donations, help center, forms, or the workspace's own data (goals/OKRs, contacts, deals, people); rendering WYSIWYG content; scaffolding projects; OR provisioning/authoring uidu content (create/update/delete) from the terminal via the @uidu/cli (`uidu`). Triggers on any task involving uidu, the @uidu/client SDK, @uidu/react bindings, or the uidu CLI.
4
4
  license: MIT
5
5
  metadata:
6
6
  author: uidu
7
- version: "0.5.0"
7
+ version: '0.6.0'
8
8
  ---
9
9
 
10
10
  # uidu SDK
@@ -13,7 +13,7 @@ The uidu SDK is a TypeScript GraphQL toolkit for building websites and apps powe
13
13
 
14
14
  - **`@uidu/client`** — framework-agnostic GraphQL client. Safe for server-side use (RSC, route handlers, server actions, scripts).
15
15
  - **`@uidu/app-bridge`** — the browser half of a custom app: the handshake with the uidu page that frames it, and its short-lived session token. Only for apps that run inside uidu.
16
- - **`@uidu/react`** — React hooks (`useUiduClient`, `useQuery`, `useFields`, `toText`, `useUiduApp`) and components (`<UiduAppProvider>`, `<PageBlocks>`, `<BlockRenderer>`, `<RichText>`, `<DynamicForm>`). That list is the **complete** public surface — there is no `useForm`, `usePage` or `useChannel`; for anything else, call a `@uidu/client` function.
16
+ - **`@uidu/react`** — React hooks (`useUiduClient`, `useQuery`, `useFields`, `toText`, `useUiduApp`) and components (`<UiduAppProvider>`, `<PageBlocks>`, `<BlockRenderer>`, `<RichText>`, `<DynamicForm>`). That list is the **complete** public surface — there is no `useForm`, `usePage` or `useChannel`; for anything else, call a `@uidu/client` function. `@uidu/client` has one for each entity uidu already holds (goals, contacts, deals, people, events… — see [Workspace data](#workspace-data)): look there before writing a query or creating a Model.
17
17
 
18
18
  `@uidu/api.js` is a legacy bridge package and is **deprecated** — use `@uidu/react` directly.
19
19
 
@@ -31,12 +31,12 @@ Use this skill when the user is:
31
31
 
32
32
  ## Package Map
33
33
 
34
- | Package | When to use | Notes |
35
- |---|---|---|
36
- | `@uidu/client` | Server-side queries (RSC, server actions, scripts) | Framework-agnostic |
37
- | `@uidu/react` | React hooks, providers, and components | Some hooks are client-only |
38
- | `@uidu/app-bridge` | An app that runs inside uidu (custom app) | Browser only; in React use `<UiduAppProvider>` |
39
- | `@uidu/api.js` | **Do not use for new code.** | Deprecated; re-exports `@uidu/react` |
34
+ | Package | When to use | Notes |
35
+ | ------------------ | -------------------------------------------------- | ---------------------------------------------- |
36
+ | `@uidu/client` | Server-side queries (RSC, server actions, scripts) | Framework-agnostic |
37
+ | `@uidu/react` | React hooks, providers, and components | Some hooks are client-only |
38
+ | `@uidu/app-bridge` | An app that runs inside uidu (custom app) | Browser only; in React use `<UiduAppProvider>` |
39
+ | `@uidu/api.js` | **Do not use for new code.** | Deprecated; re-exports `@uidu/react` |
40
40
 
41
41
  Install:
42
42
 
@@ -80,14 +80,14 @@ reports `does not support <verb>` when it doesn't. Config resolves **flags → e
80
80
 
81
81
  ## Who the app acts for — one rule
82
82
 
83
- What an app *looks like* (landing page, blog, dashboard, booking tool) changes nothing.
83
+ What an app _looks like_ (landing page, blog, dashboard, booking tool) changes nothing.
84
84
  What changes the code is **who the request acts for**:
85
85
 
86
- | Operation | Auth | Where it runs |
87
- |---|---|---|
88
- | **Reads** for anyone (`get*` / `list*`, `<entity> list/get`) | `publicToken` | anywhere — server, CLI, **and the browser** |
89
- | **Writes** as the workspace (`create*` / `update*` / `delete*`, `<entity> create/update/delete`) | account **Bearer** (`apiKey`) | CLI and **server-side** only |
90
- | **Anything as the signed-in member**, inside uidu (a custom app) | session token from the host, over `@uidu/app-bridge` | **the browser only** |
86
+ | Operation | Auth | Where it runs |
87
+ | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------------- |
88
+ | **Reads** for anyone (`get*` / `list*`, `<entity> list/get`) | `publicToken` | anywhere — server, CLI, **and the browser** |
89
+ | **Writes** as the workspace (`create*` / `update*` / `delete*`, `<entity> create/update/delete`) | account **Bearer** (`apiKey`) | CLI and **server-side** only |
90
+ | **Anything as the signed-in member**, inside uidu (a custom app) | session token from the host, over `@uidu/app-bridge` | **the browser only** |
91
91
 
92
92
  The write functions live in `@uidu/client`, so anything the CLI provisions you can also do
93
93
  from a Next.js server action / route handler / RSC. **Never put the account Bearer token in a
@@ -111,10 +111,13 @@ that person can do, and no more. It changes five things:
111
111
  needs a server credential uidu doesn't issue yet.
112
112
  3. **Treat the token as opaque.** Never decode it. The expiry comes with it, the bridge refreshes
113
113
  it, and `graphqlUrl` comes with it too: never build `https://<workspace>.uidu.org` from a slug.
114
- 4. **Data goes in the app's own Models.** The session reaches the app's own Models, fields and
115
- items through `node(id:)`, and the data-engine mutations. Everything else in the workspace comes
116
- back as an error. Keep your data there rather than in a database of your own: it stays
117
- searchable and permissioned in uidu.
114
+ 4. **Read what the workspace already has; keep only new data in the app's own Models.** The
115
+ session reaches the app's own Models, fields and items (`node(id:)` and the data-engine
116
+ mutations) and, read-only, the workspace's **goals and timeframes** (`listGoals`, `getGoal`,
117
+ `listTimeframes`) — those of the workspace and of the Space the app sits in. Everything else in
118
+ the workspace comes back as an error for now: see [Workspace data](#workspace-data) for which
119
+ entity is reachable from where. Keep the app's own data in Models rather than in a database of
120
+ your own: it stays searchable and permissioned in uidu.
118
121
  `listModelItems` reads uidu's **search index**, which catches up a moment after a write:
119
122
  after `createModelItem` / `deleteModelItem`, update your list from the mutation payload
120
123
  instead of re-listing, or the new item is missing. And load models once per app instance
@@ -129,24 +132,39 @@ import { useEffect, useState } from 'react';
129
132
  import { UiduAppProvider, useUiduApp } from '@uidu/react';
130
133
  import { DEFAULT_HOST_ORIGINS } from '@uidu/app-bridge';
131
134
  import {
132
- createModelItem, ensureModel, listModelItems, toFieldValuesAttributes,
133
- type ModelItem, type UiduClient,
135
+ createModelItem,
136
+ ensureModel,
137
+ listModelItems,
138
+ toFieldValuesAttributes,
139
+ type ModelItem,
140
+ type UiduClient,
134
141
  } from '@uidu/client';
135
142
 
136
143
  export function AppProvider({ children }: { children: React.ReactNode }) {
137
144
  // https://*.uidu.org by default; add a local uidu or a custom domain
138
- return <UiduAppProvider hostOrigins={[...DEFAULT_HOST_ORIGINS]}>{children}</UiduAppProvider>;
145
+ return (
146
+ <UiduAppProvider hostOrigins={[...DEFAULT_HOST_ORIGINS]}>
147
+ {children}
148
+ </UiduAppProvider>
149
+ );
139
150
  }
140
151
 
141
152
  async function loadBookings(client: UiduClient, workspaceAppId: string) {
142
- // interim: the app creates its own model on first load (install-time manifest comes later)
153
+ // interim: the app creates its own model on first load (install-time manifest comes later).
154
+ // Bookings of a room are data uidu has no place for — hence a Model. Goals, contacts or
155
+ // people already exist: read them (see Workspace data).
143
156
  const model = await ensureModel(client, {
144
157
  workspaceAppId,
145
158
  name: 'Booking',
146
159
  fields: [{ shortname: 'room', name: 'Room', kind: 'string' }],
147
160
  });
148
161
  await createModelItem(client, {
149
- input: { attributes: { modelId: model.id, fieldValuesAttributes: toFieldValuesAttributes(model, { room: 'Blu' }) } },
162
+ input: {
163
+ attributes: {
164
+ modelId: model.id,
165
+ fieldValuesAttributes: toFieldValuesAttributes(model, { room: 'Blu' }),
166
+ },
167
+ },
150
168
  });
151
169
  return listModelItems(client, { modelId: model.id }); // item.fieldValuesByShortname.room
152
170
  }
@@ -156,19 +174,83 @@ export function Bookings() {
156
174
  const [items, setItems] = useState<ModelItem[]>([]);
157
175
 
158
176
  useEffect(() => {
159
- if (app.status === 'ready') loadBookings(app.client, app.context.workspaceApp.id).then(setItems);
177
+ if (app.status === 'ready')
178
+ loadBookings(app.client, app.context.workspaceApp.id).then(setItems);
160
179
  }, [app]);
161
180
 
162
- if (app.status === 'error') return <p>Open this app from uidu ({app.error.code})</p>; // NOT_EMBEDDED, TIMEOUT…
163
- return <ul>{items.map((item) => <li key={item.id}>{item.fieldValuesByShortname?.room}</li>)}</ul>;
181
+ if (app.status === 'error')
182
+ return <p>Open this app from uidu ({app.error.code})</p>; // NOT_EMBEDDED, TIMEOUT…
183
+ return (
184
+ <ul>
185
+ {items.map((item) => (
186
+ <li key={item.id}>{item.fieldValuesByShortname?.room}</li>
187
+ ))}
188
+ </ul>
189
+ );
164
190
  }
165
191
  ```
166
192
 
193
+ Field `kind`s for `ensureModel` (and `public/uidu.app.json`): `string` (one line), `text`
194
+ (several lines), `number`, `currency`, `percent`, `date`, `datetime`, `checkbox`, `email`,
195
+ `phone`, `url`, `rating`, `member` (a person of the workspace). `singleSelect` /
196
+ `multipleSelect` need their options created too (`createFieldOption`) — prefer `string` unless
197
+ the choices matter. uidu knows more (`attachments`, `linkedRecord`, `formula`, `progress`,
198
+ `richText`…); leave them out unless the person asks for one.
199
+
167
200
  `app.context` carries `user`, `space`, `workspaceApp`, `locale`, `theme` and `accent` (the page's colour, to set as `--primary`). Outside React: `connect()` from
168
201
  `@uidu/app-bridge`, then `createClient(fromBridge(bridge))` from `@uidu/client`. Start a new
169
202
  one with `npm create uidu-app@latest my-app -- -t custom-app`: it declares its Models in
170
203
  `public/uidu.app.json` and sets `frame-ancestors` so only uidu can frame it.
171
204
 
205
+ ## Workspace data
206
+
207
+ **A uidu workspace is not empty.** It already holds the organisation's goals, contacts, deals,
208
+ people, events, courses… An app that asks for "a dashboard of the OKRs behind schedule" or "our
209
+ open deals" is asking about data uidu has — read it. **Create a Model only for data uidu has no
210
+ place for** (the app's own bookings, a checklist, a log). Copying existing records into a Model
211
+ gives the person a second copy that drifts from the real one.
212
+
213
+ | The workspace's… | Functions (`@uidu/client`) | Inside a custom app |
214
+ | ----------------------------------- | ----------------------------------------------------------------- | ------------------------------ |
215
+ | Goals / OKRs (key results = subgoals) | `listGoals`, `getGoal`, `listTimeframes`; `updateGoal` | **read** (no `updateGoal`) |
216
+ | Contacts, organisations | `listContacts`, `getContact`; `createContact` | not yet |
217
+ | Deals (CRM) | `listDeals`, `getDeal`; `createDeal`, `updateDeal` | not yet |
218
+ | People (employees) | `listEmployees`, `getEmployee`, `listOffices`, `listCircles`, `listRoles` | not yet |
219
+ | Tasks, notes | `updateTask`, `createTask`, `updateNote`, `createNote` (no list) | not yet |
220
+ | Events, courses, bookings, calendars | `listEvents`, `listCourses`, `listBookings`, `listCalendars`, … | not yet |
221
+ | Forms, donations, stories, help center | see [Patterns by Scope](#patterns-by-scope) | not yet |
222
+ | The app's own Models | `ensureModel`, `listModelItems`, `createModelItem`, … | read and write |
223
+
224
+ On a server with an account token (`apiKey`) every row works; "not yet" means a custom app's
225
+ session token is refused for it. **When the data exists in uidu but is not reachable from a custom
226
+ app yet, tell the person** — don't rebuild it in a Model. If the functions you need aren't in the
227
+ table, look for `listX` / `getX` in `@uidu/client` before concluding they don't exist.
228
+
229
+ ### Goals (OKRs)
230
+
231
+ An objective is a `Goal`; its key results are its `subgoals`; the period it runs over is its
232
+ `timeframe` (`startDate`, `endDate`). `metricKind` is `number`, `percentage`, `currency`,
233
+ `checkbox` or `subgoal` (measured by its key results); `status` is the owner's own judgement:
234
+ `on_track`, `needs_attention`, `off_track`, `accomplished`.
235
+
236
+ **Values are stored x100.** `initialValue`, `currentValue` and `targetValue` never hold the number
237
+ a person typed, and `progress` is percent x100 (10000 = done). Use the helpers, don't divide by hand:
238
+
239
+ ```ts
240
+ import { listGoals, goalValue, goalProgress, isGoalBehind } from '@uidu/client';
241
+
242
+ const goals = await listGoals(app.client); // all of them, objectives and key results
243
+ const objectives = goals.filter((g) => !g.parentId);
244
+ const behind = objectives.filter((g) => isGoalBehind(g, { tolerance: 0.1 }));
245
+
246
+ goalValue(goal, goal.targetValue); // 45 for a 45% target, 1200 for €1,200
247
+ goalProgress(goal); // 0.45 — done fraction
248
+ ```
249
+
250
+ `isGoalBehind` compares progress with the share of the timeframe already gone (`goalTimeElapsed`);
251
+ show the goal's `status` next to it, it says something different. `updateGoal` takes raw values:
252
+ `toGoalRaw(45)`. A goal's owner, members and activity are not readable from a custom app.
253
+
172
254
  ## Working transparently
173
255
 
174
256
  The user often can't tell what you're doing when you drive the CLI. Stay legible:
@@ -215,23 +297,66 @@ import { createClient } from '@uidu/client';
215
297
  export const uidu = createClient({
216
298
  workspace: process.env.UIDU_WORKSPACE ?? '',
217
299
  publicToken: process.env.UIDU_PUBLIC_TOKEN, // safe to expose
218
- apiKey: process.env.UIDU_API_KEY, // server-only secret
300
+ apiKey: process.env.UIDU_API_KEY, // server-only secret
219
301
  });
220
302
  ```
221
303
 
222
304
  Required environment:
223
305
 
224
- | Variable | Required | Notes |
225
- |---|---|---|
226
- | `UIDU_WORKSPACE` | yes | Your workspace slug (e.g. `acme`) |
227
- | `UIDU_PUBLIC_TOKEN` | no | Public read-only token, safe to expose |
228
- | `UIDU_API_KEY` | no | Server-only secret for privileged ops |
306
+ | Variable | Required | Notes |
307
+ | ------------------- | -------- | -------------------------------------- |
308
+ | `UIDU_WORKSPACE` | yes | Your workspace slug (e.g. `acme`) |
309
+ | `UIDU_PUBLIC_TOKEN` | no | Public read-only token, safe to expose |
310
+ | `UIDU_API_KEY` | no | Server-only secret for privileged ops |
229
311
 
230
312
  ## Patterns by Scope
231
313
 
232
- ### CMS Pages
314
+ ### CMS Sites (new CMS)
233
315
 
234
- CMS pages are made of `pageBlocks`. Fetch a page server-side, pass blocks to `<PageBlocks>` with a component map keyed by the block's `shortname`:
316
+ uidu has two CMS generations. **Sites** are the new one: a `Site` has page types (page-kind Models), pages (ModelItems of a page type) and, on each page, an ordered list of blocks (ModelItems of a block-kind Model). **Projects** (`getPage`/`listPages`, next section) are the legacy CMS — keep using them only for sites already deployed on a Project. Build new sites on Sites. Every read works with a `publicToken`, in RSC and in the browser.
317
+
318
+ ```tsx
319
+ // src/app/[[...slug]]/page.tsx — Server Component
320
+ import { getPageBySlug, getSiteByDomain, type SiteBlock } from '@uidu/client';
321
+ import { notFound } from 'next/navigation';
322
+ import { uidu } from '@/lib/uidu';
323
+
324
+ const components: Record<string, React.ComponentType<{ block: SiteBlock }>> = {
325
+ Hero,
326
+ FAQ,
327
+ };
328
+
329
+ export default async function Page({
330
+ params,
331
+ }: {
332
+ params: Promise<{ slug?: string[] }>;
333
+ }) {
334
+ const { slug = ['home'] } = await params;
335
+ const site = await getSiteByDomain(uidu, {
336
+ domain: process.env.SITE_DOMAIN!,
337
+ });
338
+ const page =
339
+ site &&
340
+ (await getPageBySlug(uidu, { siteId: site.id, slug: slug.join('/') }));
341
+ if (!page) notFound();
342
+ return page.blocks.map((block) => {
343
+ const Component = components[block.model.name ?? ''];
344
+ return Component ? <Component key={block.id} block={block} /> : null;
345
+ });
346
+ }
347
+ ```
348
+
349
+ - `getSiteByDomain(client, { domain })` / `getSite(client, { id })` — the Site (by id in previews, where there is no domain)
350
+ - `getPageBySlug(client, { siteId, slug, includeDrafts? })` → `{ id, slug, model, fields, blocks: [{ id, model, fields, position }] }`, published only by default
351
+ - `listSitePages(client, { siteId, pageType?, includeDrafts? })` — nav/sitemaps; published only, singletons excluded; `pageType` = Model id or name
352
+ - `getSingletonBlock(client, { siteId, shortname })` — header/footer/nav
353
+ - `fields` is already a `{ shortname: value }` map — no `useFields` needed.
354
+ - Block kinds have a `shortname`, but `Model.shortname` isn't exposed by the API yet: key component maps on `block.model.name` for now.
355
+ - Don't call the legacy `listPages`/`getPage` with a Site id — they read Projects only.
356
+
357
+ ### CMS Pages (legacy Projects)
358
+
359
+ Legacy CMS pages are made of `pageBlocks`. Fetch a page server-side, pass blocks to `<PageBlocks>` with a component map keyed by the block's `shortname`:
235
360
 
236
361
  ```tsx
237
362
  // src/app/page.tsx — Server Component
@@ -249,10 +374,7 @@ export default async function HomePage() {
249
374
  if (!page) return <div>Not found</div>;
250
375
 
251
376
  return (
252
- <PageBlocks
253
- pageBlocks={page.pageBlocks}
254
- components={{ Hero, Feature }}
255
- />
377
+ <PageBlocks pageBlocks={page.pageBlocks} components={{ Hero, Feature }} />
256
378
  );
257
379
  }
258
380
  ```
@@ -296,19 +418,19 @@ const event = await getEvent(uidu, { id });
296
418
 
297
419
  Event fields:
298
420
 
299
- | Field | Notes |
300
- |---|---|
301
- | `event.id` | |
302
- | `event.name` | Display name |
303
- | `event.body` | WYSIWYG doc — render with `<RichText>` |
304
- | `event.cover` | Image URL (string) or null |
305
- | `event.description` | Plain string summary |
306
- | `event.instance.beginsAt` | ISO datetime of start |
307
- | `event.instance.finishesAt` | ISO datetime of end |
308
- | `event.instance.id` | Instance identifier |
309
- | `event.primaryAddress` | Location data |
310
- | `event.currentCapacity` | Numeric capacity |
311
- | `event.isPaidEvent` | Boolean |
421
+ | Field | Notes |
422
+ | --------------------------- | -------------------------------------- |
423
+ | `event.id` | |
424
+ | `event.name` | Display name |
425
+ | `event.body` | WYSIWYG doc — render with `<RichText>` |
426
+ | `event.cover` | Image URL (string) or null |
427
+ | `event.description` | Plain string summary |
428
+ | `event.instance.beginsAt` | ISO datetime of start |
429
+ | `event.instance.finishesAt` | ISO datetime of end |
430
+ | `event.instance.id` | Instance identifier |
431
+ | `event.primaryAddress` | Location data |
432
+ | `event.currentCapacity` | Numeric capacity |
433
+ | `event.isPaidEvent` | Boolean |
312
434
 
313
435
  ### Stories (Blog)
314
436
 
@@ -395,10 +517,12 @@ later throws. `FieldValueAttributes` exposes no `value` key — `content` is the
395
517
 
396
518
  ```ts
397
519
  // ✅ Correct — every answer wrapped
398
- fieldValuesAttributes: [{ fieldId: 'f1', content: { value: 'hello@example.com' } }]
520
+ fieldValuesAttributes: [
521
+ { fieldId: 'f1', content: { value: 'hello@example.com' } },
522
+ ];
399
523
 
400
524
  // ❌ Wrong — silently loses the answer
401
- fieldValuesAttributes: [{ fieldId: 'f1', content: 'hello@example.com' }]
525
+ fieldValuesAttributes: [{ fieldId: 'f1', content: 'hello@example.com' }];
402
526
  ```
403
527
 
404
528
  It holds for **every** field kind, not just strings — `{ value: 42 }`, `{ value: true }`,
@@ -465,7 +589,9 @@ All `body` fields on Event, Story, DonationCampaign, etc. are structured documen
465
589
  ```tsx
466
590
  import { RichText } from '@uidu/react';
467
591
 
468
- {story.body != null && <RichText doc={story.body} />}
592
+ {
593
+ story.body != null && <RichText doc={story.body} />;
594
+ }
469
595
  ```
470
596
 
471
597
  The prop is `doc`, not `children`. The component handles paragraphs, headings, lists, links, and inline marks.
@@ -517,12 +643,12 @@ Or pick a template directly:
517
643
  npm create uidu-app@latest my-app -- -t events
518
644
  ```
519
645
 
520
- | Template | What it builds |
521
- |---|---|
522
- | `minimal` | CMS landing page with `PageBlocks` + Hero/Feature blocks |
523
- | `events` | Event listing + `/event/[id]` detail |
524
- | `stories` | Blog listing + `/story/[id]` detail (magazine layout) |
525
- | `donations` | Campaigns listing + `/campaign/[id]` detail with progress bars |
646
+ | Template | What it builds |
647
+ | ------------ | ------------------------------------------------------------------------------------------------ |
648
+ | `minimal` | CMS landing page with `PageBlocks` + Hero/Feature blocks |
649
+ | `events` | Event listing + `/event/[id]` detail |
650
+ | `stories` | Blog listing + `/story/[id]` detail (magazine layout) |
651
+ | `donations` | Campaigns listing + `/campaign/[id]` detail with progress bars |
526
652
  | `custom-app` | An app inside a uidu Space: `<UiduAppProvider>`, data in its own Models (`public/uidu.app.json`) |
527
653
 
528
654
  Every template ships with Next.js 16 App Router, Tailwind v4 and an `.env.example` (the CLI