@lotics/cli 0.189.0 → 0.191.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.
@@ -306,6 +306,31 @@ export interface ScaffoldWorkspaceResult {
306
306
  /** Rows were sent and none were written, because the run adopted a table. */
307
307
  rows_skipped: boolean;
308
308
  }
309
+ /**
310
+ * A workspace read BACK as a model — the inverse of the scaffold above.
311
+ *
312
+ * `entities` and `roles` are the model file's own two keys, whose authoritative
313
+ * shapes are `contractEntitySchema` / `contractRoleSchema` in `@lotics/shared` —
314
+ * specifiers a published `.d.ts` cannot resolve, so they are typed here as what
315
+ * this client does with them, which is hand them on whole. The same trade
316
+ * `ScaffoldWorkspaceRequest` makes in the other direction.
317
+ *
318
+ * `findings` are about the export rather than part of it: a workspace holds
319
+ * things a model file cannot express, and a file that dropped them silently
320
+ * would be read as the whole workspace.
321
+ */
322
+ export interface WorkspaceModelExport {
323
+ contract: {
324
+ entities: Array<Record<string, unknown>>;
325
+ roles: Array<Record<string, unknown>>;
326
+ templates: Array<Record<string, unknown>>;
327
+ };
328
+ findings: Array<{
329
+ severity: "error" | "warning" | "info";
330
+ area: string;
331
+ message: string;
332
+ }>;
333
+ }
309
334
  /**
310
335
  * A request the API refused. The message carries the status and the server's
311
336
  * sentence, which is what reaches a person; `status` and `body` are for the
@@ -573,6 +598,16 @@ export declare class LoticsClient {
573
598
  * rows land only where every bound table is empty. Admin-only.
574
599
  */
575
600
  scaffoldWorkspace(body: ScaffoldWorkspaceRequest): Promise<ScaffoldWorkspaceResult>;
601
+ /**
602
+ * Read this workspace's schema back as a model — the tables it has (or only
603
+ * the ones named), their fields, options and views, plus its roles.
604
+ *
605
+ * A pure read, and admin-only for the same reason the scaffold is: the whole
606
+ * schema is what comes back.
607
+ */
608
+ exportWorkspaceModel(opts?: {
609
+ tables?: string[];
610
+ }): Promise<WorkspaceModelExport>;
576
611
  /** Renames (and re-settings) the CURRENT workspace — the endpoint reads the
577
612
  * target from the request's workspace, never a path id. */
578
613
  updateWorkspace(body: {
@@ -356,6 +356,21 @@ var LoticsClient = class {
356
356
  async scaffoldWorkspace(body) {
357
357
  return this.request("POST", "/v1/workspaces/scaffold", body);
358
358
  }
359
+ /**
360
+ * Read this workspace's schema back as a model — the tables it has (or only
361
+ * the ones named), their fields, options and views, plus its roles.
362
+ *
363
+ * A pure read, and admin-only for the same reason the scaffold is: the whole
364
+ * schema is what comes back.
365
+ */
366
+ async exportWorkspaceModel(opts = {}) {
367
+ const params = new URLSearchParams();
368
+ if (opts.tables !== void 0 && opts.tables.length > 0) {
369
+ params.set("tables", opts.tables.join(","));
370
+ }
371
+ const qs = params.toString();
372
+ return this.request("GET", `/v1/workspaces/model${qs ? `?${qs}` : ""}`);
373
+ }
359
374
  /** Renames (and re-settings) the CURRENT workspace — the endpoint reads the
360
375
  * target from the request's workspace, never a path id. */
361
376
  async updateWorkspace(body) {
@@ -184,6 +184,11 @@ grammar, and the worked `tpl_*` screens. Reuse the template that matches; if the
184
184
  genuinely missing, build it as a kit component rather than a local one-off, or the next screen
185
185
  re-derives it differently.
186
186
 
187
+ **A screen no shape fits** is declared in the plan as `"shape": "custom"` with its slots as roles
188
+ (`lotics scaffold docs` § Apps and screens) — never a shape bent to fit — and built from the kit
189
+ like any other. Then file `lotics report` with `wanted` opening `shape <name>`: a custom slot set
190
+ that recurs becomes a shape, and the report is how the next build gets it.
191
+
187
192
  Two rules that cause most of the rework:
188
193
 
189
194
  - **Never copy server data into `useState`.** Derive from `useQuery` / `useWorkflow` with
@@ -236,14 +241,17 @@ actually carries, not what the source says it should.
236
241
 
237
242
  ```
238
243
  npm run typecheck && npm run lint && npm test
239
- lotics app check # every pre-flight a deploy runs, without building or shipping,
240
- # plus the portability gate a library publish applies
244
+ lotics app check --screens # every pre-flight a deploy runs, without building or shipping,
245
+ # plus the portability gate a library publish applies, plus
246
+ # every screen rendered at 1280 and 375 and measured
241
247
  lotics app deploy -m "<what changed + why>"
242
248
  ```
243
249
 
244
- **A green suite says nothing about how the screen LOOKS**, and that half has its own pass:
245
- `lotics docs reviewing` render it, look at it, then measure what the look is telling you. Run it
246
- before the deploy, not as an audit someone schedules after a complaint.
250
+ **A green suite says nothing about how the screen LOOKS**, and that half starts with
251
+ `--screens`: it renders the app over its real data (Chrome needed), walks every tab at both
252
+ widths, and refuses what a review would; what it measures is in `lotics docs cli_reference`. What
253
+ it cannot measure is in `lotics docs reviewing` — render it, look at it, then measure what the look
254
+ is telling you. Both before the deploy, not as an audit someone schedules after a complaint.
247
255
 
248
256
  Every check above reads the SOURCE; none of them renders it. So the entire class of defect that
249
257
  lives in the pixels — wrong form for the subject, a treatment that contradicts what an element
@@ -283,6 +291,7 @@ lotics app codegen # after any schema change
283
291
  npm run typecheck # honest, because codegen is current
284
292
  lotics run run_app_workflow '{"app_id":…,"alias":…}' # prove the mutation path
285
293
  lotics app dev # prove the screen
294
+ lotics app check --screens # measure it, before anyone looks
286
295
 
287
296
  lotics app workflow set <alias> # push the body; the server verifies
288
297
  lotics app deploy -m "…" # pushes pending bindings, then ships
@@ -6,7 +6,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
6
6
  |---|---|
7
7
  | `lotics` / `lotics --help` | Show full help with capabilities, tool categories, workflow |
8
8
  | `lotics auth signup <email>` | Create account + org + API key, sends magic link email. Registers the new org as a profile; `--local` pins this directory to it (pointer) instead of setting the global default. |
9
- | `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key. Lotics emails a sign-in link; the person opens it and presses Confirm on the page showing the code this command printed, and the command finishes with the credential saved as a profile. `--local` pins this directory to that org instead of setting the global default. `--json` prints `organization_id`, `workspace_id` and `organization_name`, and nothing else. |
9
+ | `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key. **Two steps, and it does not wait for the person.** The first prints the page to open — `https://lotics.ai/cli_login/<request_id>`, also mailed — and the code that page must show, records the request, and exits 0. They sign in there if asked, check the code and press Confirm. **Then the next command that needs a credential collects the key** before it does its own work, so the second step is just re-running whatever was wanted; a command run before Confirm exits 1 naming the page and the code again, and once the 15 minutes are up it says to ask again. The handful that run WITHOUT a credential `library list`/`show`, `scaffold docs`/`check`, `app codegen`, `app workflow check` — claim nothing, so one of those run after Confirm still answers as though signed out. `--wait` keeps one command instead, holding the terminal until Confirm; `--local` pins this directory to that org rather than setting the global default, and implies `--wait` (a pin names THIS directory, so only the terminal that stays in it can write one). `--json` prints `organization_id`, `workspace_id` and `organization_name` when it finishes signed in, and `request_id`, `confirm_url`, `code`, `email`, `expires_at` when it is the first step. The request's secret is never printed and the org's key never leaves the store. |
10
10
  | `lotics auth api-key [key]` | `whoami` → **upsert** the key's org as a profile in the global store (never overwrites). `--local` additionally pins this directory to it (pointer) instead of setting the global default. |
11
11
  | `lotics auth web` | Send a magic link email to access the web app (requires auth) |
12
12
  | `lotics auth whoami` | Print active account name, email, org, resolved workspace, and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id` + `source`. |
@@ -43,10 +43,11 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
43
43
  | `lotics app versions [app_id]` | `GET /v1/apps/{id}/versions` — print deploy history newest-first (version number, timestamp, deployer name, build status, the `-m` message; `*` marks the currently-served version). app_id from the local manifest, or pass one to inspect any app without pulling it. Admin-only server-side (mirrors deploy + source download). Answers "what shipped, when, by whom" — e.g. whether a fix was live at an incident's time. Title → stderr, table → stdout (pipeable). |
44
44
  | `lotics app upgrade [app_id]` | `POST /v1/apps/{id}/upgrade` — apply the latest version of the package this app was COPIED from. A copy records its provenance (`apps.origin`: package, version, app alias and the `bind` it was made under) and this is the only thing that reads it — a hand-built app, or one copied before the column existed, has no package to offer one and answers 400. Run it once per app: a package's apps each carry their own provenance. **The schema is additive** — fields, options and views the new version declares are created under the recorded bind, so they land on the same tables the copy did; nothing is renamed, retyped or deleted, and a field the new version stopped declaring keeps its column and its data and is REPORTED. **An artifact is replaced only while it is still byte-for-byte what was delivered**: a workflow or agent you have edited here is kept as it is and named, so the offer is partial by design and every part it declined to touch is printed. Queries are replaced outright (generated from the contract, no edit to lose) and only a knowledge doc the new version ADDS is created. The app is then redeployed from the new version's prebuilt dist — **nothing local is read or sent**, so a checkout on this machine is behind afterwards and the report ends at `lotics app pull <app_id>`. app_id from the local manifest, or pass one to upgrade any app without pulling it. **Already on the latest version prints that one line and exits 0** — it is a refusal before the first write, not a failure, and re-applying the version it is on would re-stamp your edits as delivered. Every other refusal (an unpublished package, a contract that no longer validates, a bind the new version broke) is a package that cannot be applied: the app is untouched, the server's sentence is printed, and the exit is 1. Anything else — no provenance to read, not an admin, no such app — exits 1. Admin-only. Audited as `app.upgrade`. |
45
45
  | `lotics app codegen [path]` | Regenerate `.lotics/*` from the manifest + workspace schema **without a deploy**. The three `.d.ts` companions (`app_{workflows,queries,agents}.d.ts`) are always rewritten (synchronous, no network). When credentials resolve, also rewrites the **runtime** `.lotics/app_fields.ts` — a real `.ts` exporting `F` (table→field→`"fld_…"`) + `OPT` (table→select-field→option→`"opt_…"`) keyed by display-name aliases, for the tables the app's queries reference (+ optional `package.json#lotics.codegen.tables` allowlist). **There is one form, and that is what makes a starter's source portable**: the keys are slugified DISPLAY NAMES and a starter carries its labels verbatim, so running codegen in a copy's own workspace emits the same keys pointing at that workspace's ids — no binding fetched at load, no prebuilt bundle to keep in step. Also refreshes each bound workflow's `.lotics/workflows/<alias>.globals.d.ts` + re-wraps its EXISTING `src/workflows/<alias>.ts` body in the current envelope (strips + re-wraps; never re-fetches the body, so local edits survive). **`.lotics/` is reconciled to the manifest, not merely added to** — a `<alias>.globals.d.ts` whose alias the manifest no longer declares is DELETED. Only that exact filename shape is removed; anything else in the directory is left alone. The reconcile runs before the credential branch, so it happens offline too. The authored counterpart is never deleted — a `src/workflows/<alias>.ts` the manifest does not declare is NAMED instead (`check` and `set` both take their alias set from the manifest, so editing an undeclared body is a silent no-op). A getApp / binding / schema / dts-fetch failure is non-fatal (warns, keeps the last-generated files). **Re-silvers `package.json#lotics.agents`** from the live app row whenever its `inputs`/`outputs` disagree, then rewrites the agent `.d.ts` from the refreshed block: that block is a mirror AND the offline seed for `useAgentRun` typings, so a stale copy types the app against an agent that does not exist. The write is surgical and order-preserving, so it changes only the fields that actually differ. A hand edit to that block is therefore reverted — it never changed the agent anyway; to change one, `set_app_agent`. |
46
- | `lotics setup <apg_id \| model.json> [--email <addr>] [--json]` | **The whole first run, in one command.** Creates an account when this machine has no credential (the same call `auth signup` makes — `--name` and `--timezone` apply), then fills its workspace, then prints the one-time sign-in link. **When that email already has an account it signs that account in rather than stopping** — the `lotics auth login` flow, so Lotics emails a sign-in link, the person presses Confirm, and the command carries on into the copy or the model with the credential it just received. **The argument decides which of the two forms this is, by SHAPE**: a `*.json` file is a workspace MODEL — in either of ITS two forms, spelled out or `{"from": "<preset-slug>", …}` — and anything else is a package id copied through `library init`. The suffix decides it alone — asking the filesystem would answer a long library id with `ENAMETOOLONG` instead of with a verdict — and a model is checked OFFLINE before an account is created, because a file with a typo in it must not leave an organization behind. The model form creates no apps, so its sign-in link lands on the first table it made. It sends no `adopt`: an entity whose `label` already names a table in the workspace is REFUSED with every collision named, and the refusal adds the line the server cannot — `lotics scaffold apply <model.json>`, the verb that adds to the workspace you already have. It exists because the two-command form has a seam where the FIRST command exists only to produce a credential for the second, and a caller pasting a prompt has to get both right. **`--email` is only for creating an account**: with a credential already resolvable it is REFUSED rather than obeyed, because the two can name different organizations and preferring either one silently copies a package into an org the caller did not name — the message says how to do each thing on purpose. Without it, `setup` copies into the account you already have and is a pure alias for `library init`. A path positional is accepted and IGNORED with a warning — nothing is written to disk any more — so a prompt written for an older CLI still runs. **`--json` prints one object on stdout and nothing else** — `organization_id`, `workspace_id`, `app_ids` (alias → id), `apps` (each app's `version_number`, or its `error`), `signin_url`, and `created` — which NAMES what landed (`tables`, `templates` and `knowledge_docs` are alias arrays; `sample_records` is a row count, since rows are not named things). Aliases rather than counts because the next question is about a particular artifact: a copied template carries the publisher's wording and a copied knowledge doc describes how they work, so "which of these should be mine?" is the conversation a copy starts, and a count cannot begin it. **The model form emits `entities`, `roles`, `record_ids` and `rows_skipped`** in place of `app_ids` / `apps` / `created` — a model creates no apps and nothing named for a copier to review. **The model form also runs the file's `apply` list** — each named package copied in after the tables exist, with that entry's `bind`, in order, stopping at a refusal with everything before it kept — and emits `applied: [{starter_id, apps}]` beside them; **the sign-in link then lands on the FIRST app any applied package created**, falling back to the first table when the model applied none. Plus a `warnings` array carrying everything the prose form would have said out of band — an unbindable knowledge doc, a sign-in link that could not be minted, the publisher's-code disclosure, an app that landed without a version. A warning is never merely silenced: when the command fails with an error before it can emit, the ones it had collected go to stderr alongside it. Reachable with no install: `npx -y @lotics/cli setup …`. |
47
- | `lotics scaffold docs` | **The model reference, from inside the binary.** Every top-level key of a `model.json`, every field `type` the contract admits with the config each one needs, the option / view / role / inline-template shapes, the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `"<entity-alias>:<ref>"`), the rules, the `apply` list (packages copied in after the model's own tables, each with an optional `bind` onto them), the `preset` block (a published model's branches and its at-most-two questions), the **`from` form** — `{from, variants, rename, entities, rows, apply}`, which names a preset by SLUG instead of restating it — and one complete worked example. **Offline, no account**, and not part of `lotics docs`. |
48
- | `lotics scaffold check <model.json> [--json]` | **Prove a model before anyone sees it — no network, no credential**, unless the file names a preset. ONE parse of the whole file against the model schema (strict, so `tabels` or `row` is an error rather than a silently dropped key, and a model cannot express what only a starter bundle carries: `apps`, `fixtures`, `knowledge`, `knowledge_expects`, a file-backed `excel`/`word`/`pdf-form` template), then `validateWorkspaceModel` — the caps, every cross-reference, and the first rows themselves (a field the entity does not declare, an unknown option alias, a link naming no row in the file, a duplicate `ref`, a date that is not one, a value on a files or computed field). **Reports EVERY problem in one run**, each as `<path>: <message>` in the file's own keys (`entities.0.fields.1.type`, `rows.order.so_1.customer`), so fixing a model is not a round trip per mistake. Exits 1 when there is one; exits 0 with a one-line summary (`N tables, N fields, N links, N views, N roles, N rows`) on stdout. `--json` replaces both with one object and nothing else: `{ok: true, tables, fields, links, views, roles, rows}` or `{ok: false, findings: [{path, message}]}`. **A `preset` is checked as N models, not one** — every variant merged onto the base (its added entities, and its added fields keyed by entity) and put through the same rules, each finding addressed `preset.variants.<slug>.<path>`, so a preset ships with every branch proven: the branch nobody took is the one that fails in the workspace of whoever takes it, who is the one reader who cannot fix it. A variant's `fields` key naming no declared entity is a finding too — the merge keys on the entity, so a typo'd alias adds those fields to nothing. Same verdict the server reaches, because it runs the server's own functions out of `@lotics/shared` rather than a second implementation of them. **A file written as `{"from": "<preset-slug>", …}` is resolved first** — one GET of that preset's file on the website — and that read is the one step on this path that needs the network; it says so when it cannot make it, and a slug nothing serves is answered with the slugs there ARE, read from the listing, rather than with a 404 the author cannot spell their way out of. Resolution is pure (`resolveModelFrom` in `@lotics/shared`): the named variants merged onto the preset's base in order, then `rename` through the same `applyBinding` a `--bind` goes through, then the file's own `entities` appended. What comes out is the full form and goes through everything above unchanged, so a `from` file cannot reach a workspace by a route the full form does not. A variant slug the preset does not declare, an alias `rename` names that it does not declare, and a renamed label that is already another table's are each a finding rather than a silent drop — a branch quietly ignored scaffolds the base and looks like it worked. |
49
- | `lotics scaffold apply <model.json> [--json]` | **Create the model in this workspace**: its tables, fields, select options, links, views, roles and first rows, through `POST /v1/workspaces/scaffold`. Runs `check` first, so a bad file never reaches the network, then resolves and ANNOUNCES its workspace (`lotics → <org> / <workspace>` on stderr) before writing — it is a destructive path. **Additive and re-runnable**: it is the verb that sends `adopt`, so an entity whose `label` already names a table here BINDS to that table and gains the fields, options and views it is missing, while `setup` refuses that same label. Nothing is ever modified or deleted, so applying the same model twice creates nothing the second time. **A renamed label therefore asks for a NEW table** — rename through `lotics run update_table` instead; after the first run the workspace is the source of truth and the file is an authoring input. **Rows land only where every bound table is empty**: one bound table already holding records and none are written anywhere, because sample rows landing among a customer's real ones cannot be told apart from them — it says so and reports `rows_skipped`. Prints `created`/`adopted` per entity with its table id, each role's group id, and rows written per entity. **Then it copies in every package the file's `apply` list names, in order** — each one a `library init` with that entry's `bind` and `no_sample_data`, and each sending `adopt`, because by then the workspace holds exactly the tables this same run just created. Order is load-bearing: a later entry may bind onto a table an earlier one made. **A refused entry stops the run and the entries before it stay** — they are separate copies, committed as they land — so the refusal carries the server's own message plus what already landed and the one-package command to retry with. `--json` prints one object and nothing else (`entities`, `roles`, `record_ids`, `rows_skipped`, `applied: [{starter_id, apps}]` — always present, empty included, so a reader cannot mistake "applied nothing" for "too old to say" — plus `organization_id`/`workspace_id` and a `warnings` array). Admin-only. A model declares no apps of its own — build one in the workspace and publish it as a package, or name a published package in `apply`. |
46
+ | `lotics setup <apg_id \| model.json> [--email <addr>] [--json]` | **The whole first run, in one command.** Creates an account when this machine has no credential (the same call `auth signup` makes — `--name` and `--timezone` apply), then fills its workspace, then prints the one-time sign-in link. **When that email already has an account it hands over to the `lotics auth login` flow** it prints the sign-in page to open and the code it must show, and **exits 1 having created nothing**; the person presses Confirm and runs the same command again, which collects the key and carries on into the copy or the model. (`--wait` holds the terminal through the Confirm instead, finishing in one command.) The re-run is not refused for naming an `--email` it is now signed in as — that address IS the account it holds, not a second one. **The argument decides which of the two forms this is, by SHAPE**: a `*.json` file is a workspace MODEL — in either of ITS two forms, spelled out or `{"from": "<preset-slug>", …}` — and anything else is a package id copied through `library init`. The suffix decides it alone — asking the filesystem would answer a long library id with `ENAMETOOLONG` instead of with a verdict — and a model is checked OFFLINE before an account is created, because a file with a typo in it must not leave an organization behind. The model form creates no apps, so its sign-in link lands on the first table it made. It sends no `adopt`: an entity whose `label` already names a table in the workspace is REFUSED with every collision named, and the refusal adds the line the server cannot — `lotics scaffold apply <model.json>`, the verb that adds to the workspace you already have. It exists because the two-command form has a seam where the FIRST command exists only to produce a credential for the second, and a caller pasting a prompt has to get both right. **`--email` is only for creating an account**: with a credential already resolvable it is REFUSED rather than obeyed, because the two can name different organizations and preferring either one silently copies a package into an org the caller did not name — the message says how to do each thing on purpose. Without it, `setup` copies into the account you already have and is a pure alias for `library init`. A path positional is accepted and IGNORED with a warning — nothing is written to disk any more — so a prompt written for an older CLI still runs. **`--json` prints one object on stdout and nothing else** — `organization_id`, `workspace_id`, `app_ids` (alias → id), `apps` (each app's `version_number`, or its `error`), `signin_url`, and `created` — which NAMES what landed (`tables`, `templates` and `knowledge_docs` are alias arrays; `sample_records` is a row count, since rows are not named things). Aliases rather than counts because the next question is about a particular artifact: a copied template carries the publisher's wording and a copied knowledge doc describes how they work, so "which of these should be mine?" is the conversation a copy starts, and a count cannot begin it. **The model form emits `entities`, `roles`, `record_ids` and `rows_skipped`** in place of `app_ids` / `apps` / `created` — a model creates no apps and nothing named for a copier to review. **The model form also runs the file's `apply` list** — each named package copied in after the tables exist, with that entry's `bind`, in order, stopping at a refusal with everything before it kept — and emits `applied: [{package, apps}]` beside them; **the sign-in link then lands on the FIRST app any applied package created**, falling back to the first table when the model applied none. Plus a `warnings` array carrying everything the prose form would have said out of band — an unbindable knowledge doc, a sign-in link that could not be minted, the publisher's-code disclosure, an app that landed without a version. A warning is never merely silenced: when the command fails with an error before it can emit, the ones it had collected go to stderr alongside it. Reachable with no install: `npx -y @lotics/cli setup …`. |
47
+ | `lotics scaffold docs` | **The model reference, from inside the binary.** Every top-level key of a `model.json`, every field `type` the contract admits with the config each one needs, the option / view / role / inline-template shapes, the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `"<entity-alias>:<ref>"`), the rules, the `apply` list (packages copied in after the model's own tables, each with an optional `bind` onto them), the `preset` block (a published model's branches and its at-most-two questions), the **`from` form** — `{from, variants, rename, entities, rows, field_roles, apps, apply}`, which names a preset by SLUG instead of restating it — and one complete worked example. **Offline, no account**, and not part of `lotics docs`. |
48
+ | `lotics scaffold check <model.json> [--json]` | **Prove a model before anyone sees it — no network, no credential**, unless the file names a preset. ONE parse of the whole file against the model schema (strict, so `tabels` or `row` is an error rather than a silently dropped key, and a model cannot express what only a starter bundle carries: `fixtures`, `knowledge`, `knowledge_expects`, a file-backed `excel`/`word`/`pdf-form` template; its `apps` are a PLAN of screens, never built code), then `validateWorkspaceModel` — the caps, every cross-reference, and the first rows themselves (a field the entity does not declare, an unknown option alias, a link naming no row in the file, a duplicate `ref`, a date that is not one, a value on a platform-computed field, a files cell that is not a relative path beside the model or a `fil_` id, a document path with no file beside the model), then `field_roles` — every role on a field its type can answer — and the screen plan, every shape's slots bound from those roles, a required slot nothing fills refused. **Reports EVERY problem in one run**, each as `<path>: <message>` in the file's own keys (`entities.0.fields.1.type`, `rows.order.so_1.customer`), so fixing a model is not a round trip per mistake. Exits 1 when there is one; exits 0 with the counts (`N tables, N fields, N links, N views, N roles, N rows`, plus `N apps, N screens` when the file plans any and `N custom` when a screen has no shape), then the plan — one line per screen with the field in each slot — then what the first rows would show, all on stdout. `--json` replaces both with one object and nothing else: `{ok: true, tables, fields, links, views, roles, rows, apps, screens, custom, plan, coverage}` or `{ok: false, findings: [{path, message}]}`. **A `preset` is checked as N models, not one** — every variant merged onto the base (its added entities, and its added fields keyed by entity) and put through the same rules, each finding addressed `preset.variants.<slug>.<path>`, so a preset ships with every branch proven: the branch nobody took is the one that fails in the workspace of whoever takes it, who is the one reader who cannot fix it. A variant's `fields` key naming no declared entity is a finding too — the merge keys on the entity, so a typo'd alias adds those fields to nothing. Same verdict the server reaches, because it runs the server's own functions out of `@lotics/shared` rather than a second implementation of them. **A file written as `{"from": "<preset-slug>", …}` is resolved first** — one GET of that preset's file on the website — and that read is the one step on this path that needs the network; it says so when it cannot make it, and a slug nothing serves is answered with the slugs there ARE, read from the listing, rather than with a 404 the author cannot spell their way out of. Resolution is pure (`resolveModelFrom` in `@lotics/shared`): the named variants merged onto the preset's base in order, then `rename` through the same `applyBinding` a `--bind` goes through, then the file's own `entities` appended. What comes out is the full form and goes through everything above unchanged, so a `from` file cannot reach a workspace by a route the full form does not. A variant slug the preset does not declare, an alias `rename` names that it does not declare, and a renamed label that is already another table's are each a finding rather than a silent drop — a branch quietly ignored scaffolds the base and looks like it worked. |
49
+ | `lotics scaffold apply <model.json> [--json]` | **Create the model in this workspace**: its tables, fields, select options, links, views, roles and first rows, through `POST /v1/workspaces/scaffold`. Runs `check` first, so a bad file never reaches the network, then resolves and ANNOUNCES its workspace (`lotics → <org> / <workspace>` on stderr) before writing — it is a destructive path. **Additive and re-runnable**: it is the verb that sends `adopt`, so an entity whose `label` already names a table here BINDS to that table and gains the fields, options and views it is missing, while `setup` refuses that same label. Nothing is ever modified or deleted, so applying the same model twice creates nothing the second time. **A renamed label therefore asks for a NEW table** — rename through `lotics run update_table` instead; after the first run the workspace is the source of truth and the file is an authoring input. **Rows land only where every bound table is empty**: one bound table already holding records and none are written anywhere, because sample rows landing among a customer's real ones cannot be told apart from them — it says so and reports `rows_skipped`. Prints `created`/`adopted` per entity with its table id, each role's group id, and rows written per entity. **Then it copies in every package the file's `apply` list names, in order** — each one a `library init` with that entry's `bind` and `no_sample_data`, and each sending `adopt`, because by then the workspace holds exactly the tables this same run just created. Order is load-bearing: a later entry may bind onto a table an earlier one made. **A refused entry stops the run and the entries before it stay** — they are separate copies, committed as they land — so the refusal carries the server's own message plus what already landed and the one-package command to retry with. `--json` prints one object and nothing else (`entities`, `roles`, `record_ids`, `rows_skipped`, `applied: [{package, apps}]` — always present, empty included, so a reader cannot mistake "applied nothing" for "too old to say" — plus `organization_id`/`workspace_id` and a `warnings` array). Admin-only. A model PLANS its apps as shapes over entities and builds none of them`apply` creates no app; build one in the workspace and publish it as a package, or name a published package in `apply`. |
50
+ | `lotics scaffold export [--tables <tbl_id,…>]` | **This workspace, read back as a model file** — `GET /v1/workspaces/model`. Prints the tables it has (or only the ids `--tables` names) with their fields, options and views, plus its roles and its html/email templates when it has any (a file-backed template is named on stderr and left out), as pretty JSON on **stdout**: exactly the file `lotics scaffold check` reads, so `lotics scaffold export > model.json && lotics scaffold check model.json` is the round trip. Resolves and ANNOUNCES its workspace first (`lotics → <org> / <workspace>` on stderr), like every other verb that reads one. **Findings go to stderr, each led by its severity** (`• <severity> <area>: <message>`, and one line counting the errors underneath) — a workspace holds things a model cannot express, and a file that dropped them silently would read as the whole workspace; the model is printed either way, and the exit is 1 when any finding is an `error`, because a file with a hole in it is still worth having on disk. **What comes out is a STARTING POINT, never a source of truth**: it carries one business's labels and stops describing that workspace the moment either changes. Edit the labels into the trade's words, add the `preset` block with its questions and variants (`lotics scaffold docs`), and prove every branch with `lotics scaffold check` before it is published. Admin-only. |
50
51
  | `lotics library list` | **Works with no account**, and that is the point: whether to start from a preset, copy a package or build from scratch is decided before one exists, so requiring a key would mean signing up to learn the answer was no. **Two shelves, printed under their own headings and never merged**, because they are different kinds of thing and end in different commands. **Presets** are a trade's MODEL, served as static files on the website (`GET <site>/presets/index.json`, no credential, no server that knows what a preset is): each row is `slug · name`, the sentence, how many tables the base carries, and every branch as `slug · when`. The `when` rides the listing rather than waiting for a `show`, because it is what an answer is matched against — two trades whose names sound alike are told apart by which one has a branch describing the business in front of the reader. A preset is READ and turned into a `model.json`; nothing is copied. **Packages** are apps plus the tables they stand on, COPIED in whole. Unauthenticated it lists what Lotics publishes (`GET /v1/starters/official`, public); authenticated it lists the org shelf — the packages this organization can copy, Lotics-reviewed ones plus its own, each with at least one released version, deliberately NOT a catalogue of everything published: the server returns exactly what a copy would be allowed to take, so the list can never offer something that then refuses (admin-only). Both render through one function, and each row names WHAT IS INSIDE it — its apps and how many tables — because that is the fact the choice turns on: a name and a sentence leave a chooser guessing, and an agent matching what someone said they manage has nothing else to match against. Nothing fitting on either shelf is a real answer: `lotics scaffold docs` is where that goes. |
51
52
  | `lotics library show <slug\|apg_id> [--json]` | **The argument says which shelf**, and both forms are allowlists rather than a fallback: an `apg_` id is a package, anything else is a preset slug (`^[a-z0-9_]+$`, refused before any request — a slug reaches a URL). **A SLUG** reads the preset's own file off the website with no credential and no account, which is the whole timing argument for serving it as a file: it prints the preset's name and sentence, the questions it may ask (at most two), every table as `alias · label` with each field as `alias:type`, and every branch as `slug · when` followed by the tables and fields taking it ADDS — a slug picked off its `when` alone cannot say whether the branch brings the column the person was asked about. It closes with the `{"from": …}` file to write. The file is proven as a MODEL on the way through (the same `readPresetModel` this repo's own test runs over these files), so a preset that would fail in the workspace of whoever takes a branch is refused here, named in the preset's own keys — ours to fix, not the reader's. **An `apg_` id** prints the package: name, description, current version, shelf tile and trust standing (`official` — reviewed by Lotics; `your organization's own`; otherwise `not copyable from this organization`), plus the date it was unpublished once it has been, then the same COMPACT table listing — each table as `alias · label`, each field as `alias:type` — which is exactly what a `--bind` is typed from. Read it before copying a package you did not publish. **Works with no account for anything Lotics publishes**, falling to `GET /v1/starters/official/{id}` the way `list` falls to the public shelf; signed in, the prose form is admin-only and readable by id from any org, but an unpublished package 404s for every org except the one that published it. `--json` prints the preset FILE for a slug, and the published contract read whole — views, labels and all — for a package: ONE shape whichever credentials the caller holds, because the reader of that object is a program writing a model from it. |
52
53
  | `lotics library init <apg_id> [--bind <entity>=<Label> ...]` | **Copy a package into this workspace.** Server-side it scaffolds the tables and fields, creates the document templates and knowledge docs, inserts the sample records, creates every app the package carries and materializes each one's queries, workflows and agents onto it — then deploys each app from the dist the package was published with, rewriting the publisher's sentinel field keys to this workspace's. No build runs anywhere, nothing is written to this machine, and nothing here needs node: the apps are live when the command returns. **What you get is yours outright**: ordinary apps plus ordinary tables, with no link back to what it came from and nothing pinned. It does STAMP what delivered it (`apps.origin`), which nothing resolves through and only `lotics app upgrade <app_id>` reads. Edit any of it — `lotics app pull <app_id>` is how an app's code is edited afterwards. **The publisher's code runs in your workspace as you** — its apps, workflows and agents — which is why provenance is the gate: **copyable only if the package is Lotics-reviewed or your own organization published it**, enforced server-side; the disclosure is printed (and carried in `--json`'s `warnings`) whenever the package is not your own. **Refuses a workspace that already has tables** unless `--adopt`: scaffold matches an entity by DISPLAY NAME, so a package declaring `Contacts` would bind to yours. An app whose deploy failed is reported by name with its reason and the exit is non-zero, but the copy is complete around it — the tables, the records and the app row exist — so it must not be run again; the publisher fixes the package and it is copied into a fresh workspace. The sign-in link lands on the app when there is one, else on the workspace's app list. `--json` prints one object on stdout instead of progress (the shape is under `lotics setup`). `--no-sample-data` skips the sample records, and a copy that ADOPTS an existing table writes none either — that table already holds real rows, and the fixture set links to itself, so it is all-or-nothing; with them, how many landed is reported. They are ordinary records, delete them whenever. **`--bind <entity>=<Label>` says which of YOUR tables the package's entities are, and `--bind <entity>.<field>=<Label>` which of your fields** — repeatable, and split on the FIRST `=` so a label may contain one. Scaffold adopts by DISPLAY LABEL, so a bind renames the contract to what you already call things and the copy lands on your tables instead of creating a second set beside them: this is how a package of apps lands on a workspace that already has its tables. Only naming moves — a bound field must be the TYPE the package declares, or the copy is refused (409). A bound entity needs no `--adopt`. The same target named twice is refused rather than overwritten, because the caller then believes one of the two took. `lotics library show <apg_id>` lists the aliases to bind. Resolves and ANNOUNCES its workspace first (`lotics → <org> / <workspace>` on stderr). Admin-only. Authoring the registry (`opctl library publish/unpublish`) stays operator-only. |
@@ -54,7 +55,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
54
55
  | `lotics upgrade` | Update this CLI in place. Runs the same installer a person would, chosen by how THIS copy arrived: an npm install upgrades through npm, a script install re-runs the script — the runtime knows which (the executable is compiled, the npm bin runs under node), so nobody has to. It downloads nothing itself; resolving a version, verifying the checksum and replacing a running executable already exist in the installers, and a second copy of that inside the binary would be a second thing to get right. Replacing the binary while it runs is safe — a rename leaves the running image mapped on unix, and on Windows the installer moves the old aside precisely because the file is in use. Already current is a no-op that says so. Needs no auth. |
55
56
  | `lotics docs` \| `lotics docs <area>` | The index of the reference docs, **resolved out of the packages installed beside this project** — never carried by this CLI. **Both levels are discovered by looking**: every `@lotics/*` package carrying an `AGENTS.md` or a `docs/` in any `node_modules/@lotics` from the current directory UPWARD (nearest wins, so a hoisted root copy never shadows the one a project's own imports resolve to), and within each, every area it actually ships. Titles come from each file's own `# heading` and the version from the installed `package.json`, so a doc OR a whole package added upstream appears with no change to this CLI, and a skewed install is visible rather than reassuring. A package's index is named after the package (`lotics docs ui`), never `index`. `@lotics/app-sdk`, `@lotics/ui` and `@lotics/cli` sort first as a reading ORDER, not a filter. Both the index and `<area>` print to **stdout** — the index is the payload of a bare `lotics docs`, so `lotics docs | grep -i excel` works — with only the provenance line on stderr, so `lotics docs ai > ai.md` is the doc alone; a name two packages share is refused with both qualified forms (`lotics docs ui/templates`) rather than resolved silently. Needs no auth. Outside a project only `@lotics/cli`'s own resolve, and it says so. |
56
57
  | `lotics report '<json>'` \| `lotics report @report.json` | File a report with the Lotics team about what got in your way. **Covers the classes telemetry structurally cannot see**: a capability that does not exist (no command ran, so nothing was recorded), a command that exited 0 having done the wrong thing, an error whose message did not name the remedy, and anything that made authoring slower than it should be. **A frame, not a paragraph** — `{goal, actual, expected?, tried?, wanted?}`, `goal` and `actual` required, unknown keys dropped rather than refused. **No severity or category.** Ingest is inline JSON, `@file`, or `-` for stdin. A bare sentence is refused with the frame printed beside it, so the fix is one step; a bare invocation prints the frame BEFORE asking for a credential, since someone whose key will not resolve is exactly who has something to report. **Not spooled**: unlike telemetry it posts inline, prints whether it landed, and exits non-zero if it did not, echoing the report back so a failed send never loses it. Runs regardless of `LOTICS_TELEMETRY` — invoking it IS the consent that passive collection needs an opt-in for — but with telemetry off there are no recorded commands to attach, and it says so rather than implying context it does not have. Requires auth. Never paste records, file contents, or credentials. |
57
- | `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping. **First, whether this project is even based on the served version** — the one thing a deploy REFUSES outright rather than pushing (the server 409s a stale `prev_version_id`), and the one finding that invalidates every other: a stale tree and the live app are two different apps, so comparing them reports nothing trustworthy. Stale exits 1 naming both versions and stops before the rest; a project with no stamp at all — or an app with no version yet — is a first deploy, not a conflict. `deploy` runs the SAME assertion off the app row it already fetched, so a stale tree fails before it pushes a binding or builds, instead of after the upload arrives and the server 409s. Then: the manifest's agent schemas against the live app row, every binding a deploy would push, aliases the source calls that nothing bound (queries, workflows AND agents), bindings this bundle stopped calling (the same transition — and the same baseline — `deploy` reports, so the two cannot disagree), capability-gated SDK calls the manifest doesn't declare, a missing icon/theme, a missing app `description` (it heads the capability catalog the chat agent reads every turn, and its absence has no other symptom), a `vite.config.ts` that never defines `global`/`__DEV__`, a `window.open` in the app's own source, and an INSTALLED `@lotics/app-sdk` below the version that understands the host's realtime push — read from `node_modules`, not the dependency range, because a caret is minor-locked below 1.0 so `^0.79.x` can never resolve `0.80` and `npm update` does nothing (all three fail ONLY in the deployed app — dev bundles with esbuild and production with rollup, so typecheck, lint, build and `app dev` are all green while react-native-web reads `global.cancelAnimationFrame` as a free variable and the sandboxed iframe drops a popup silently), an agent whose capability and its reach disagree, in EITHER direction, over any of the five declaration-bound tools (`run_app_query`/`run_app_workflow` against `query_aliases`/`workflow_aliases`; `grep_knowledge`/`read_knowledge`/`list_knowledge` against `knowledge_doc_ids`) — the tool is the capability, the list is the reach, and a tool with no reach means every call it makes is refused while the run still COMPLETES, so it surfaces as a model ignoring its prompt; read off the live row, never the manifest, which mirrors those fields but is pushed by no verb, and a notice for any alias the source computes at runtime (invisible to every check here and to `--prune`'s unbind guard). **And whether the kit this app builds against has fallen behind what is published** — `@lotics/ui` and `@lotics/app-sdk`, read from `node_modules` for the same reason as the floor check above: a range keeps accepting, so an app pinned `^44.x` reads healthy for a year, and even an in-range one sits on the lockfile's older patch until `npm update` (never `npm install`, which honours the lock). A MAJOR behind is loud and names the packages actually behind — plus `@lotics/ui`'s `MIGRATION.md`, when ui is one of them, since it is the only half that keeps one; anything smaller is one quiet line, because a warning that fires on every deploy is one the reader stops seeing. The registry lookup is bounded and every failure — offline, slow, private — is silence: a version check must never become a new way for a deploy to fail. Every deploy finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **And the PORTABILITY gate, which is the one rule `check` runs that a deploy does not** — the ids an app cannot carry into another workspace, over the working tree, with the same exclusions the deploy tar applies. Two rules. **An id this workspace MINTED**, written into `src/`, a `.md` or the app's own docs — it resolves to nothing in a copy, and in prose it is an instruction the copier's agent follows; this is the one a `library publish` also refuses, on the uploaded archive. **And an id-shaped STAND-IN** too short for the generator that mints its prefix (`"opt_X"`, `"fld_a"` — quoted or in a code span, so a bare `opt_in` stays legal, and never in a test file), which only `check` runs, and which additionally reads a workflow body and the manifest: those two are exempt from the first rule because a publish INVERTS a real id there, and it cannot invert a fake — so without this a stand-in survives until the publish resolves it against the app's footprint, on somebody else's machine. Each is reported as `<file>:<line> — <id>` with the one edit that fixes it. This gate reads only the files, but the command around it still needs a resolvable credential and the live app row, so it is not an offline check. **And every bound workflow body, type-checked locally** — the same isolated per-alias program `app workflow check` builds, against the pulled `.lotics/workflows/<alias>.globals.d.ts`. The server verifies a body once, at the save that wrote it, so a helper whose declared signature has since moved (`toNumber` returning `number | null`) leaves it stored, matching what is live, and refused by the next writer — a starter copy, in somebody else's workspace. The verdict is as fresh as those types, which `pull`, `workflow pull` and `codegen` refresh. **And the app's own `npm run typecheck`**, after regenerating the `.lotics/*.d.ts` companions from the manifest — the same run a deploy makes before building, so a filter or sort key the query does not project fails here rather than at the first member's request. **Exits 1 on that, on a body the types refuse, on a failing typecheck, and on what a `deploy` would REFUSE or PUSH** — an agent schema that disagrees with the live app, and any binding the project has ahead of the app (an edited workflow body or declaration, edited agent prose, a changed query). Both are things a deploy would act on, so CI gating on a green check means a deploy has nothing left to do; genuine advisories (capabilities, branding, a runtime-computed alias, orphaned bindings) stay advisory and never fail it. |
58
+ | `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping. **First, whether this project is even based on the served version** — the one thing a deploy REFUSES outright rather than pushing (the server 409s a stale `prev_version_id`), and the one finding that invalidates every other: a stale tree and the live app are two different apps, so comparing them reports nothing trustworthy. Stale exits 1 naming both versions and stops before the rest; a project with no stamp at all — or an app with no version yet — is a first deploy, not a conflict. `deploy` runs the SAME assertion off the app row it already fetched, so a stale tree fails before it pushes a binding or builds, instead of after the upload arrives and the server 409s. Then: the manifest's agent schemas against the live app row, every binding a deploy would push, aliases the source calls that nothing bound (queries, workflows AND agents), bindings this bundle stopped calling (the same transition — and the same baseline — `deploy` reports, so the two cannot disagree), capability-gated SDK calls the manifest doesn't declare, a missing icon/theme, a missing app `description` (it heads the capability catalog the chat agent reads every turn, and its absence has no other symptom), a `vite.config.ts` that never defines `global`/`__DEV__`, a `window.open` in the app's own source, and an INSTALLED `@lotics/app-sdk` below the version that understands the host's realtime push — read from `node_modules`, not the dependency range, because a caret is minor-locked below 1.0 so `^0.79.x` can never resolve `0.80` and `npm update` does nothing (all three fail ONLY in the deployed app — dev bundles with esbuild and production with rollup, so typecheck, lint, build and `app dev` are all green while react-native-web reads `global.cancelAnimationFrame` as a free variable and the sandboxed iframe drops a popup silently), an agent whose capability and its reach disagree, in EITHER direction, over any of the five declaration-bound tools (`run_app_query`/`run_app_workflow` against `query_aliases`/`workflow_aliases`; `grep_knowledge`/`read_knowledge`/`list_knowledge` against `knowledge_doc_ids`) — the tool is the capability, the list is the reach, and a tool with no reach means every call it makes is refused while the run still COMPLETES, so it surfaces as a model ignoring its prompt; read off the live row, never the manifest, which mirrors those fields but is pushed by no verb, and a notice for any alias the source computes at runtime (invisible to every check here and to `--prune`'s unbind guard). **And whether the kit this app builds against has fallen behind what is published** — `@lotics/ui` and `@lotics/app-sdk`, read from `node_modules` for the same reason as the floor check above: a range keeps accepting, so an app pinned `^44.x` reads healthy for a year, and even an in-range one sits on the lockfile's older patch until `npm update` (never `npm install`, which honours the lock). A MAJOR behind is loud and names the packages actually behind — plus `@lotics/ui`'s `MIGRATION.md`, when ui is one of them, since it is the only half that keeps one; anything smaller is one quiet line, because a warning that fires on every deploy is one the reader stops seeing. The registry lookup is bounded and every failure — offline, slow, private — is silence: a version check must never become a new way for a deploy to fail. Every deploy finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **And the PORTABILITY gate, which is the one rule `check` runs that a deploy does not** — the ids an app cannot carry into another workspace, over the working tree, with the same exclusions the deploy tar applies. Two rules. **An id this workspace MINTED**, written into `src/`, a `.md` or the app's own docs — it resolves to nothing in a copy, and in prose it is an instruction the copier's agent follows; this is the one a `library publish` also refuses, on the uploaded archive. **And an id-shaped STAND-IN** too short for the generator that mints its prefix (`"opt_X"`, `"fld_a"` — quoted or in a code span, so a bare `opt_in` stays legal, and never in a test file), which only `check` runs, and which additionally reads a workflow body and the manifest: those two are exempt from the first rule because a publish INVERTS a real id there, and it cannot invert a fake — so without this a stand-in survives until the publish resolves it against the app's footprint, on somebody else's machine. Each is reported as `<file>:<line> — <id>` with the one edit that fixes it. This gate reads only the files, but the command around it still needs a resolvable credential and the live app row, so it is not an offline check. **And every bound workflow body, type-checked locally** — the same isolated per-alias program `app workflow check` builds, against the pulled `.lotics/workflows/<alias>.globals.d.ts`. The server verifies a body once, at the save that wrote it, so a helper whose declared signature has since moved (`toNumber` returning `number | null`) leaves it stored, matching what is live, and refused by the next writer — a starter copy, in somebody else's workspace. The verdict is as fresh as those types, which `pull`, `workflow pull` and `codegen` refresh. **And the app's own `npm run typecheck`**, after regenerating the `.lotics/*.d.ts` companions from the manifest — the same run a deploy makes before building, so a filter or sort key the query does not project fails here rather than at the first member's request. **Exits 1 on that, on a body the types refuse, on a failing typecheck, and on what a `deploy` would REFUSE or PUSH** — an agent schema that disagrees with the live app, and any binding the project has ahead of the app (an edited workflow body or declaration, edited agent prose, a changed query). Both are things a deploy would act on, so CI gating on a green check means a deploy has nothing left to do; genuine advisories (capabilities, branding, a runtime-computed alias, orphaned bindings) stay advisory and never fail it. **`--screens` adds the rendered surface**: the app is served the way `app dev` serves it (its real data, this key), rendered headless in Chrome (`CHROME_PATH`/`LOTICS_CHROME`, then Playwright's, then system) at 1280 and 375, every tab of its first strip walked once no request is in flight, and the measurable probes of `@lotics/ui` docs/reviewing.md run over the DOM — money strings on more than two right edges in one column of three or more figures, eight or more values as bare text with nothing drawn, an internal id in the text, more than one tab strip, text cut or clamped at 375, the ` · ` glyph, compact money in the other currency's words, a stacked pair demoted twice, two identity marks at two rungs on one row. A census per screen (text runs, money strings, bare against encoded values) prints first, so a clean verdict over a screen that rendered nothing cannot pass; a screen that renders no text is itself a finding, and one still changing after fifteen seconds is measured as it is and reported. Findings exit 1 like the rest. Runs after the typecheck and only when it passed — a type error renders nothing worth measuring. |
58
59
  | `lotics app workflow set <alias>` | Push the edited `src/workflows/<alias>.ts` body through `set_app_workflow` (the single author of `apps.workflows`). Reads the body from disk (header + `/// <reference>` + `export {};` marker + the `__workflow` wrapper all stripped) + the typed `inputs`/`outputs` **and the `description`** from `package.json#lotics.workflows.<alias>`; the **server** re-verifies the body and echoes the bound `outputs` (declared, else DERIVED from `return({ data })`). The `description` is the one line an agent reads when choosing between the app's aliases (the workflow counterpart to a query's) — authored in the manifest so it lives beside the body in version control and rides every push; omit it and the workflow keeps whatever description it already has, so a push can never blank one set elsewhere. When the manifest declared NO `outputs`, the DERIVED echo is written back into `package.json#lotics.workflows.<alias>.outputs` (a SURGICAL write — preserves `knowledge`/`config` and every other manifest field) and that alias's types are refreshed in place, so `useWorkflow("<alias>")`'s `result.data` is typed immediately with no hand-copy and no second `lotics app codegen`; an explicitly-declared `outputs` is authoritative and never overwritten. Deploy still never authors workflows — this is a CLI convenience over the existing tool. Clear error + non-zero exit on a missing file, an alias absent from the manifest, or a verify failure. |
59
60
  | `lotics app agent set <alias>` | Push `src/agents/<alias>.md` — plus `inputs`/`outputs` when `package.json#lotics.agents.<alias>` declares them — through `set_app_agent`. The agent mirror of `app workflow set`, and the deploy-free authoring path for an agent's prose and its typed edges. **It sends only those fields.** Everything else is absent, and absent means unchanged, so a declaration this CLI does not model cannot be reverted by a push from a checkout that predates it — the chat authoring agent's `knowledge_doc_ids`, another operator's `query_aliases` grant. To change one of those, call `set_app_agent` with just that field (`lotics run set_app_agent '{"app_id":…,"alias":…,"tool_names":[…]}'` — it merges), then `app pull` to bring the manifest back in step. **CREATES the alias when the app has not bound one yet**, so a new agent is authored the same way a new workflow is: write the prose, declare the typed half, push. A create needs the prose file (an agent without instructions is not an agent); it is gated on nothing else, because what keeps a binding alive is a `useAppAgentRun("<alias>")` call site in the shipped bundle — a deploy prunes an agent the bundle never names, manifest entry or not. The prose push is a conditional write against the fingerprint this project last saw, so it is refused rather than allowed to overwrite prose someone else changed. Clear error + non-zero exit when there is no prose file and nothing declared to push instead, when a create has no prose to create from, or when the file is empty once the header is stripped. |
60
61
  | `lotics app query set <alias>` \| `--all` | Push `package.json#lotics.queries` (`{ ast, params? }` per alias) to `apps.queries` through `set_app_query` — **the only author of a query binding**, the mirror of `app workflow set`. A deploy pushes a DRIFTED declaration through this same verb before it ships (see `app deploy`), so this is the explicit single-alias path, not the only way a query reaches the app. The **server** validates each one exactly as it always did (alias identifier, workspace-only tables, resolvable fields, declared params). `--all` pushes every declared alias, alias-sorted, stopping at the first failure and naming what already landed. Clear error + non-zero exit on an alias absent from the manifest or a validation failure. **The declaration's fields MERGE**, so the manifest is not a snapshot: deleting `params` from an alias and pushing leaves the live params exactly where they were, because an absent key means "unchanged". Clear one with `params: null`, or replace the map with the set you want. After the push it regenerates `.lotics/app_queries.d.ts` from the manifest, so the types the next `npm run typecheck` reads match what was just pushed. |
@@ -66,5 +67,5 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
66
67
  | `LOTICS_UI_SRC=<abs path to packages/ui/src>` (env, not a command) | Dev-link `@lotics/ui` to a monorepo checkout for the length of ONE command, **for every tool at once**. The app's `vite.config.ts` gets its whole `resolve` block from the kit (`resolve: loticsResolve()` — `@lotics/ui/vite`), which reads the variable at call time and adds the `@lotics/ui/*` → working-copy alias, so kit edits go live under `lotics app dev` (HMR) and bundle under `lotics app deploy`. In the same breath, every command that regenerates types (`create`/`pull`/`dev`/`deploy`/`codegen`, all via `writeAppDts`) writes **`.lotics/tsconfig.link.json`** — the matching `paths`, which the app's `tsconfig.json` `extends` — so `tsc`, vitest, eslint and your EDITOR resolve the same copy Vite does. Unset ⇒ every one of them goes back to `node_modules`, and the generated file is rewritten inert. **Why `paths` and not `npm link`:** the kit ships un-built `.tsx`, so a kit file outside `node_modules` resolves its OWN `react`/`react-native` from the monorepo — two copies in one program and every shared type stops matching ("Two different types with this name exist, but they are unrelated"). The generated file therefore also pins every peer @lotics/ui declares to the APP's copy, types-package first (`react` → `@types/react`; pinning the runtime package instead strands tsc on a `.js` with no declarations). The pin set is derived from the installed kit's `peerDependencies`, so it tracks the kit rather than rotting. **Nothing hand-written is touched** — the generated file lives in `.lotics/` (the CLI's own dir) and no config is edited by regex. Identical for a monorepo app and an EXTERNAL one (e.g. `~/lotics_apps`). `app deploy` still warns whenever the variable is set — that the bundle carries kit code from your working copy, or that the app's config predates `loticsResolve()` and never reads it, so the PUBLISHED kit is going out. An app whose `tsconfig.json` already `extends` something else is told rather than rewritten: add `./.lotics/tsconfig.link.json` to the array yourself. |
67
68
  | `lotics xlsx <subcmd>` | Local .xlsx read/write/edit using the bundled `@lotics/xlsx` engine (no auth, no network). `write` takes its JSON inline, as `@file`, or piped on stdin, ingested exactly as `run` ingests tool args. 14 named subcommands (read, write, set-cell, clear-range, merge, unmerge, add-sheet, delete-sheet, rename-sheet, insert-rows, delete-rows, insert-cols, delete-cols, set-style) + `batch` for applying multiple of the same 14 ops in a single parse/export cycle. `read` also takes `--sheet <name>` (limit output to one sheet — unknown name fails with the available list) and `--range <sheet>!<A1:G60>` (limit to a cell window; the `<sheet>!` prefix is optional when `--sheet` supplies the sheet, a single cell like `S1!B2` is a 1×1 window) to trim a large workbook's JSON — the output shape is unchanged, only the `sheets` array and each sheet's `cells` map are filtered. **`read` reports formatting back, so a generated file is verifiable through this path** rather than by unzipping OOXML: each cell carries `numFmt` when the file gave it one, and `--with-format` adds the resolved `style`. The asymmetry is deliberate — a parsed cell's style is *never* absent (every cell resolves to at least a font — size, name, colour), so emitting it by default would put three noise keys on every plain cell and make “is this styled?” unanswerable by presence; `numFmt` is genuinely absent on an unformatted cell, so it needs no flag. **`write` takes sheet-level `colWidths` (`{"A":34}`) and `rowHeights` (`{"1":44}`)** — without them every column is the default width and a human-facing workbook is unreadable no matter what the cells say. Both are written *pinned* (`customWidth`/`customHeight`), so Excel does not auto-fit them away, and both apply to a row/column that holds no cells (a spacer row's height survives). Keys are a bare column letter and a bare row number, bounded by Excel's grid (`A`…`XFD`, `1`…`1048576`): a key outside it, or a cell ref like `A1` where a column letter belongs, is **rejected** rather than resolved to something adjacent — past the grid the reference is written into the file verbatim, addressing a cell that cannot exist. Unknown **sheet** properties are rejected on the same terms as unknown cell properties — a silently-ignored `columnWidths` typo is a file that looks written and is not. **A `--flag` a subcommand does not know is refused by name** (`--with-formats` would otherwise read as proof the file carries no styles). `xlsx` and `docx` own their whole tail: a global flag's NAME means nothing there, so `xlsx delete-rows f.xlsx S1 5 3 --force` is refused rather than run, and `xlsx set-cell f.xlsx S1!A1 -v` writes the value `-v`. Subcommands whose trailing arg is CONTENT (`xlsx set-cell`, `docx replace-text`, `docx append-paragraph`/`insert-paragraph`) are deliberately exempt: a value may legitimately begin with `-` or `--`, and there a typo is indistinguishable from data. `--help` is the one spelling still reserved everywhere. They are covered instead by arity — **every fixed-shape subcommand refuses an argument past the last one it reads**, whatever it looks like, because the likeliest source is a flag the caller believes exists and these commands write in place. Arity rather than a leading `--` is the discriminator, since a sheet name may legitimately begin with one. **A cell VALUE is read as the type the caller stated, on both JSON surfaces.** `write`'s `cells` and `batch`'s `set-cell` `value` take the same union — a bare `string | number | boolean | null`, or a `{value, formula, numFmt, style}` object — and honour it: a JSON string writes a text cell, digits and all, so `"0071000512345"` (MST, số tài khoản, số vận đơn) keeps its leading zeros and `"1234567890123456789"` keeps its last two digits, neither of which survives being re-read as characters. The one reading applied to a BARE string is a leading `=`, which is a formula — the only way to write one in the shorthand form; `{"value": "=SUM(A1)"}` is the stated literal, and how a cell that must hold the text `=x` is expressed, on either surface. `value` is a literal on the object form of BOTH surfaces — `formula` is the key that says otherwise — and a literal beginning with `=` is **written as asked and named in a stderr warning**, since it is the one literal indistinguishable from a mistake: it renders in a viewer exactly like the formula the caller probably meant, computes nothing, and is skipped by every SUM over the column. The object form also carries `numFmt` and `style` per cell in `batch`, the same as in `write`. The `set-cell` POSITIONAL is different because a shell argument carries no type: there the characters are read for what they denote (`TRUE` → boolean, digits → number), stopped by two things — the target cell's number format being Text (`@`), and a zero-padded digit string, which stays text whatever the target format says (a deliberate divergence from Excel: losing a leading zero is unrecoverable, while a text cell in a number column is visible). Every subcommand that can introduce a formula (`write`, `set-cell`, `batch`) **evaluates it and writes the cached value**, so a generated formula does not read back blank: Excel and Sheets recalculate on open, but parsers — including this CLI's `read` and the rest of the platform — take the cached `<v>`. A formula the engine cannot evaluate still gets written, with a stderr warning naming the cells, rather than silently leaving a hole where a number belongs. Atomic in-place write (temp file + rename). |
68
69
  | `lotics docx <subcmd>` | Local .docx read/write/edit using the bundled `@lotics/docx` engine (OOXML round-trip surface only — no ProseMirror baggage). `write` takes its JSON inline, as `@file`, or piped on stdin, ingested exactly as `run` ingests tool args. Subcommands: read, write, append-paragraph, insert-paragraph, delete-block, replace-text, batch. A legacy `.doc` (Word 97–2003 OLE2 binary) is detected in `loadFile` and routed through `@lotics/ooxml`'s `loadDocxFromBuffer` (which re-emits it as real OOXML) before reading — so `lotics docx read` works on a `.doc`, not just a `.docx`. Opaque blocks (tables, custom XML) preserved verbatim. Atomic in-place write. **`replace-text` matches across run boundaries** — Word splits a run at every formatting change, so a `{{marker}}` routinely lands split — and reads straight THROUGH marks that occupy no place in the sentence (`w:proofErr`, `w:footnoteReference`, endnote/comment refs + ranges, `w:bookmarkStart`/`End`, `w:lastRenderedPageBreak`). `w:proofErr` is the one that decides whether this works in practice — Word brackets every word its dictionary rejects, so on non-English text it lands between nearly every pair of runs. It still refuses to join across anything that occupies space in the text — `w:br`, `w:tab`, `w:sym`, a drawing, or any tag not on that allowlist — because the joined string does not represent the glyph and a match there would rewrite text the caller never saw. The SAME rule applies inside a table cell as outside it — both run one `replaceInParagraph` over paragraphs found at any depth, so a marker split by a line break is refused in both rather than rewritten in the cell and skipped in the body under a success message. Zero matches is always a hard error, never a silent no-op, and when the words ARE on the page the error names the block and the splitting mark (`The text IS present at block 1, split by w:br …`) rather than claiming the text is absent. |
69
- | `lotics file preview <file\|fil_id> [-o out.png]` | (also `lotics preview`) Render a .docx/.xlsx to a PNG using the SAME engines the frontend FilePreview uses (`@lotics/docx` `loadDocxIntoElement` / `@lotics/xlsx` `drawSpreadsheet`) — so what you see matches an operator. Accepts a **local path** OR a stored **`fil_…` id** (`isStoredFileId` — a bare id, no extension): an id is first downloaded to a temp dir via `downloadFileById` (the `signed_url` presign path — same authority as `lotics file download`), rendered, then the transient source is removed; with no `-o` the PNG lands in cwd under the stored file's base name (`defaultPreviewOutputPath`). Drives a headless Chrome over **CDP with only Node built-ins** (`WebSocket`/`fetch`/`http`/`child_process`) — zero npm deps, the CLI stays a single bundled binary. The browser render logic is a separate esbuild **browser** bundle shipped at `dist/render_page.js` (built by `build_cli.mjs`, excluded from the node `tsgo`), served over a throwaway localhost http server and screenshotted full-page. **Requires a Chrome/Chromium on the machine** — detected from `CHROME_PATH`/`LOTICS_CHROME`, then Playwright's installed chromium, then system paths — inherent to rendering these browser formats; a clear "install a browser" error otherwise. PDFs need no render (open them directly). |
70
+ | `lotics file preview <file\|fil_id> [-o out.png]` | (also `lotics preview`) A `.html` renders as the page it is — served from its own directory so what it refers to beside it resolves, read once its images have loaded, captured at its content size — which is how a demo's paper props (an official letter, a stamped minute, a supplier's bill) are looked at before they go into an `html` template. Otherwise render a .docx/.xlsx to a PNG using the SAME engines the frontend FilePreview uses (`@lotics/docx` `loadDocxIntoElement` / `@lotics/xlsx` `drawSpreadsheet`) — so what you see matches an operator. Accepts a **local path** OR a stored **`fil_…` id** (a bare id, no extension): an id is first downloaded to a temp dir (the same presign path as `lotics file download`), rendered, then the transient source is removed; with no `-o` the PNG lands in cwd under the stored file's base name. Drives a headless Chrome over **CDP with only Node built-ins** — zero npm deps, the CLI stays a single bundled binary. The browser render logic is a separate browser bundle shipped at `dist/render_page.js`, served over a throwaway localhost http server and screenshotted full-page. **Requires a Chrome/Chromium on the machine** — detected from `CHROME_PATH`/`LOTICS_CHROME`, then Playwright's installed chromium, then system paths — inherent to rendering these browser formats; a clear "install a browser" error otherwise. PDFs need no render (open them directly). |
70
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.189.0",
3
+ "version": "0.191.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {