@lotics/cli 0.180.0 → 0.181.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.
package/dist/src/cli.js CHANGED
@@ -45711,7 +45711,7 @@ function resultSideEffects(result) {
45711
45711
  }
45712
45712
 
45713
45713
  // src/version.ts
45714
- var VERSION = "0.180.0";
45714
+ var VERSION = "0.181.0";
45715
45715
 
45716
45716
  // src/timezone.ts
45717
45717
  function machineTimezone() {
@@ -46112,6 +46112,53 @@ function matchesKnownFlag(known, arg) {
46112
46112
  return known.some((k) => k.endsWith("=") ? arg.startsWith(k) : arg === k);
46113
46113
  }
46114
46114
 
46115
+ // src/inputs.ts
46116
+ function shellQuotingHint(raw, source) {
46117
+ if (source !== "inline") return null;
46118
+ if (!/^\s*[[{]/.test(raw) || raw.includes('"')) return null;
46119
+ return "No double quotes reached the CLI \u2014 the shell consumed them, which PowerShell does to an inline argument. Write the JSON to a file and pass it as @args.json, or pipe it on stdin; neither goes through the shell's quoting.";
46120
+ }
46121
+ function readStdin() {
46122
+ return new Promise((resolve2, reject2) => {
46123
+ const chunks = [];
46124
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
46125
+ process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8").trim()));
46126
+ process.stdin.on("error", reject2);
46127
+ });
46128
+ }
46129
+ async function ingestJsonArgs(opts) {
46130
+ let raw = opts.rawArg;
46131
+ let source = "inline";
46132
+ if (raw && raw.startsWith("@")) {
46133
+ source = "file";
46134
+ const argsPath = raw.slice(1);
46135
+ try {
46136
+ raw = opts.readFile(argsPath);
46137
+ } catch (err2) {
46138
+ return {
46139
+ kind: "error",
46140
+ message: `Cannot read args file "${argsPath}": ${err2 instanceof Error ? err2.message : String(err2)}`
46141
+ };
46142
+ }
46143
+ } else if (!raw && !opts.stdinIsTTY) {
46144
+ source = "stdin";
46145
+ raw = await opts.readStdin();
46146
+ }
46147
+ if (!raw) return { kind: "ok", args: {} };
46148
+ try {
46149
+ const parsed = JSON.parse(raw);
46150
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
46151
+ return { kind: "error", message: `JSON args must be an object, got: ${raw}` };
46152
+ }
46153
+ return { kind: "ok", args: parsed };
46154
+ } catch {
46155
+ const hint = shellQuotingHint(raw, source);
46156
+ return { kind: "error", message: `Invalid JSON: ${raw}${hint ? `
46157
+
46158
+ ${hint}` : ""}` };
46159
+ }
46160
+ }
46161
+
46115
46162
  // src/report_command.ts
46116
46163
  import fs6 from "node:fs";
46117
46164
  import path6 from "node:path";
@@ -46141,15 +46188,18 @@ There is no severity or category to pick. How bad and how common are read off
46141
46188
  the corpus; what you were trying to do is not recoverable from anywhere else.
46142
46189
 
46143
46190
  Do not paste customer records, file contents, or credentials.`;
46144
- function parseReport(raw) {
46191
+ function parseReport(raw, source) {
46145
46192
  const text = raw.trim();
46146
46193
  if (text === "") return { error: "Nothing to report." };
46147
46194
  let parsed;
46148
46195
  try {
46149
46196
  parsed = JSON.parse(text);
46150
46197
  } catch {
46198
+ const hint = shellQuotingHint(text, source);
46151
46199
  return {
46152
- error: text.startsWith("{") ? "That is not valid JSON." : "A report is a JSON object, not a sentence \u2014 the frame below is what makes it debuggable."
46200
+ error: (text.startsWith("{") ? "That is not valid JSON." : "A report is a JSON object, not a sentence \u2014 the frame below is what makes it debuggable.") + (hint ? `
46201
+
46202
+ ${hint}` : "")
46153
46203
  };
46154
46204
  }
46155
46205
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
@@ -74110,6 +74160,10 @@ function instantiateBody(args) {
74110
74160
  }
74111
74161
  async function starterInit(client, args) {
74112
74162
  const starter = await client.getPackage(args.starter_id);
74163
+ const targetPath = path10.resolve(args.targetPath ?? appDirName(starter.name));
74164
+ if (fs9.existsSync(targetPath) && fs9.readdirSync(targetPath).length > 0) {
74165
+ throw new Error(`Target directory ${targetPath} is not empty. Pass an empty path and re-run.`);
74166
+ }
74113
74167
  note(
74114
74168
  `Copying ${starter.name}${starter.is_official ? " (official)" : ""} into this workspace\u2026`
74115
74169
  );
@@ -74142,12 +74196,6 @@ Done \u2014 these are yours now, with no link back to the starter.`);
74142
74196
  signin_url: null
74143
74197
  };
74144
74198
  }
74145
- const targetPath = path10.resolve(args.targetPath ?? appDirName(starter.name));
74146
- if (fs9.existsSync(targetPath) && fs9.readdirSync(targetPath).length > 0) {
74147
- throw new Error(
74148
- `Target directory ${targetPath} is not empty. The app (${result.app_id}) was created \u2014 pass an empty path and re-run, or finish it with "lotics app pull ${result.app_id}".`
74149
- );
74150
- }
74151
74199
  fs9.mkdirSync(targetPath, { recursive: true });
74152
74200
  note(`Downloading source\u2026`);
74153
74201
  const bundleFile = path10.join(tmpdir2(), `lotics-starter-${args.starter_id}-${process.pid}.tar.gz`);
@@ -74314,34 +74362,6 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
74314
74362
  );
74315
74363
  }
74316
74364
 
74317
- // src/inputs.ts
74318
- async function ingestJsonArgs(opts) {
74319
- let raw = opts.rawArg;
74320
- if (raw && raw.startsWith("@")) {
74321
- const argsPath = raw.slice(1);
74322
- try {
74323
- raw = opts.readFile(argsPath);
74324
- } catch (err2) {
74325
- return {
74326
- kind: "error",
74327
- message: `Cannot read args file "${argsPath}": ${err2 instanceof Error ? err2.message : String(err2)}`
74328
- };
74329
- }
74330
- } else if (!raw && !opts.stdinIsTTY) {
74331
- raw = await opts.readStdin();
74332
- }
74333
- if (!raw) return { kind: "ok", args: {} };
74334
- try {
74335
- const parsed = JSON.parse(raw);
74336
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
74337
- return { kind: "error", message: `JSON args must be an object, got: ${raw}` };
74338
- }
74339
- return { kind: "ok", args: parsed };
74340
- } catch {
74341
- return { kind: "error", message: `Invalid JSON: ${raw}` };
74342
- }
74343
- }
74344
-
74345
74365
  // src/org_commands.ts
74346
74366
  function printWorkspaceList(workspaces, currentId) {
74347
74367
  for (const ws of workspaces) {
@@ -96557,14 +96577,19 @@ function xlsxRead(filePath, rest2) {
96557
96577
  const data2 = readToJson(filePath, filter3, { withFormat });
96558
96578
  console.log(JSON.stringify(data2, null, 2));
96559
96579
  }
96560
- function xlsxWrite(filePath, json2) {
96561
- if (!filePath || !json2) fail2("Usage: lotics xlsx write <file> '<json>'");
96562
- let parsed;
96563
- try {
96564
- parsed = JSON.parse(json2);
96565
- } catch (e) {
96566
- fail2(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
96580
+ async function xlsxWrite(filePath, json2) {
96581
+ const stdinIsTTY = process.stdin.isTTY ?? false;
96582
+ if (!filePath || !json2 && stdinIsTTY) {
96583
+ fail2("Usage: lotics xlsx write <file> '<json>' | @workbook.json | < workbook.json");
96567
96584
  }
96585
+ const ingested = await ingestJsonArgs({
96586
+ rawArg: json2,
96587
+ stdinIsTTY,
96588
+ readFile: (p) => fs11.readFileSync(p, "utf-8"),
96589
+ readStdin
96590
+ });
96591
+ if (ingested.kind === "error") fail2(ingested.message);
96592
+ const parsed = ingested.args;
96568
96593
  const workbook = buildWorkbookFromJson(parsed);
96569
96594
  const report = recomputeAll(workbook);
96570
96595
  if (report.unevaluated.length > 0) {
@@ -96825,7 +96850,7 @@ Uses Lotics' own xlsx engine; round-trips faithfully with the Lotics editor and
96825
96850
  (its <sheet>! prefix is optional when --sheet is given)
96826
96851
  A cell carries numFmt when it has one; --with-format
96827
96852
  adds the resolved style (always present, hence a flag)
96828
- lotics xlsx write <file> '<json>' Create .xlsx from {"sheets":[{"name","cells":{"A1":...}}]}
96853
+ lotics xlsx write <file> '<json>' | @workbook.json | < workbook.json Create .xlsx from {"sheets":[{"name","cells":{"A1":...}}]}
96829
96854
  A cell is a bare value, or {value|formula, numFmt, style}:
96830
96855
  {"A1":{"value":1234,"numFmt":"#,##0 \\"\u20AB\\"","style":{"fontBold":true}}}
96831
96856
  A bare "=A1+B1" is a formula; an object's "value" is
@@ -102449,13 +102474,18 @@ async function docxRead(filePath, rest2) {
102449
102474
  console.log(JSON.stringify(readToJson2(doc, includeOpaque), null, 2));
102450
102475
  }
102451
102476
  async function docxWrite(filePath, json2) {
102452
- if (!filePath || !json2) fail2("Usage: lotics docx write <file> '<json>'");
102453
- let parsed;
102454
- try {
102455
- parsed = JSON.parse(json2);
102456
- } catch (e) {
102457
- fail2(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
102458
- }
102477
+ const stdinIsTTY = process.stdin.isTTY ?? false;
102478
+ if (!filePath || !json2 && stdinIsTTY) {
102479
+ fail2("Usage: lotics docx write <file> '<json>' | @doc.json | < doc.json");
102480
+ }
102481
+ const ingested = await ingestJsonArgs({
102482
+ rawArg: json2,
102483
+ stdinIsTTY,
102484
+ readFile: (p) => fs12.readFileSync(p, "utf-8"),
102485
+ readStdin
102486
+ });
102487
+ if (ingested.kind === "error") fail2(ingested.message);
102488
+ const parsed = ingested.args;
102459
102489
  const doc = buildDoc(parsed);
102460
102490
  await writeDoc(filePath, doc);
102461
102491
  }
@@ -102763,7 +102793,7 @@ function printDocxHelp() {
102763
102793
  Uses Lotics' own OOXML engine; round-trips faithfully with the Lotics editor and templates.
102764
102794
 
102765
102795
  lotics docx read <file> [--include-opaque] Dump file as JSON (blocks with text/style)
102766
- lotics docx write <file> '<json>' Create .docx from {"blocks":[{"kind":"paragraph","text":...}]}
102796
+ lotics docx write <file> '<json>' | @doc.json | < doc.json Create .docx from {"blocks":[{"kind":"paragraph","text":...}]}
102767
102797
  lotics docx append-paragraph <file> '<text>' [--style=NAME]
102768
102798
  Append a paragraph (styles: Title, Heading1..3, Quote, Code)
102769
102799
  lotics docx insert-paragraph <file> '<text>' --at=<i> [--style=NAME]
@@ -103353,14 +103383,6 @@ Resolution precedence (highest first):
103353
103383
  --api-key flag > LOTICS_API_KEY env > LOTICS_ORG env > local .lotics/config.json
103354
103384
  > global active profile. LOTICS_WORKSPACE / --workspace override the workspace.`);
103355
103385
  }
103356
- function readStdin() {
103357
- return new Promise((resolve2, reject2) => {
103358
- const chunks = [];
103359
- process.stdin.on("data", (chunk) => chunks.push(chunk));
103360
- process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8").trim()));
103361
- process.stdin.on("error", reject2);
103362
- });
103363
- }
103364
103386
  function prompt(question) {
103365
103387
  const rl = readline.createInterface({
103366
103388
  input: process.stdin,
@@ -103796,7 +103818,9 @@ async function main() {
103796
103818
  }
103797
103819
  if (command === "report") {
103798
103820
  let input = subcommand ?? "";
103821
+ let source = "inline";
103799
103822
  if (input.startsWith("@")) {
103823
+ source = "file";
103800
103824
  const file2 = input.slice(1);
103801
103825
  if (!fs14.existsSync(file2)) {
103802
103826
  console.error(`No such file: ${file2}`);
@@ -103804,13 +103828,14 @@ async function main() {
103804
103828
  }
103805
103829
  input = fs14.readFileSync(file2, "utf-8");
103806
103830
  } else if (input === "-") {
103831
+ source = "stdin";
103807
103832
  input = await readStdin();
103808
103833
  }
103809
103834
  if (input.trim() === "") {
103810
103835
  console.error(REPORT_USAGE);
103811
103836
  process.exit(1);
103812
103837
  }
103813
- const parsed2 = parseReport(input);
103838
+ const parsed2 = parseReport(input, source);
103814
103839
  if ("error" in parsed2) {
103815
103840
  console.error(`${parsed2.error}
103816
103841
  `);
@@ -20,7 +20,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
20
20
  | `lotics workspace doctor` | Report workspace-wide dangling schema references via `GET /v1/workspaces/dangling-references` — every active app/workflow artifact whose prefixed schema id no longer resolves, printed as `<referent.kind> "<name>" (<id>) → <namespace> <id> (missing)`; healthy prints a one-line all-clear. **Exits non-zero (exit 1) on findings** so scripts can gate on it. Resolves the first workspace like every data command (runs before the global workspace resolution). Admin-only. |
21
21
  | `lotics tools` | List tools by category with descriptions |
22
22
  | `lotics tools <name>` | Full description + JSON Schema for one tool |
23
- | `lotics run <tool> '<json>'` | Execute a tool (text output via toModelOutput). Args may also come from a file (`lotics run <tool> @args.json`) or piped stdin (`cat args.json \| lotics run <tool>`) — both bypass the OS `ARG_MAX` limit for large payloads (a knowledge-doc `content`, a bulk update). A leading `@` on the args is unambiguously a file path (JSON args start with `{`). |
23
+ | `lotics run <tool> '<json>'` | Execute a tool (text output via toModelOutput). Args may also come from a file (`lotics run <tool> @args.json`) or piped stdin (`cat args.json \| lotics run <tool>`) — both bypass the OS `ARG_MAX` limit for large payloads (a knowledge-doc `content`, a bulk update). A leading `@` on the args is unambiguously a file path (JSON args start with `{`). In PowerShell use `@file`: quotes inside an inline argument are consumed by the shell, and the CLI reports the JSON it received with its quotes gone — the error names both escapes. |
24
24
  | `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |
25
25
  | `lotics run <tool>` — **file cells** | A file in a tool's result carries its `fil_…` id and metadata and **no `url`**, on every tool and in both output modes. That is not a broken file — this surface resolves no URL for a cell. Reach the bytes with `lotics file download <file_id>`, which takes the id straight from the cell; the text output says so whenever a result carries one. |
26
26
  | — | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_agent`, `run_app_query`). What stays an `app` command is work no tool call can do — scaffold, build, typecheck, serve, or read and push a local file. |
@@ -59,7 +59,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
59
59
  | `lotics app rename "<new name>"` | Change the app's display name (launcher/title) via the `update_app` tool. app_id comes from the local `package.json` manifest; the public address (`subdomain`) and code (`deploy`) are unchanged. |
60
60
  | `lotics app dev [path] [--port=N] [--vite-port=N] [--view-as=<member_id>]` | Spawn Vite dev server + an RPC-forwarding HTTP server. The wrapper page embeds the iframe with `sandbox="allow-scripts allow-same-origin"` matching production; postMessage ops (query / workflow / members / context / upload / openExternal / urlState / agentRun) are forwarded to api.lotics.ai using the CLI's API key — file bytes move in **both** directions through the dev server's own relays, never browser↔storage: dev runs against the PROD bucket, whose CORS admits `https://*.lotics.app` and not `http://localhost:<port>`, so a direct browser transfer is blocked — no upload could complete and no preview engine (PDF/Word/Excel all FETCH the bytes) could read a file. `upload` mints a presigned URL and PUTs it **to `PUT /_upload/<file_id>`** (`dev/upload_relay.ts`) from the wrapper page — same-origin, so no preflight and no CORS — and Node forwards it on; every presigned `url`/`thumbnail_url`/`preview_url` on a **file object** in an RPC result is rewritten to **`GET /_file/<token>`** (`dev/file_relay.ts`, absolute — the iframe would resolve a relative path against Vite), which streams the bytes back with `Range` passthrough (206s intact, so PDF seeking works) and an `Access-Control-Allow-Origin` for the Vite origin (the one cross-origin hop left is OUR response to allow). Neither relay ever takes a destination from the client — it gets a `file_id`/token and transfers only to/from a URL it minted or observed itself, so there is no client-controlled target and no SSRF surface. A URL in a record's own text cell is NOT rewritten. Production is unchanged (direct-to-storage, no bytes through the API server); `openExternal` and `urlState.get/set` are handled locally (the latter read/write the wrapper page's own address bar — `set` writes in place via `replaceState` and browser back/forward broadcast a `url-state` message back, so `useUrlState` survives refresh and is shareable in the dev loop; in-app *routing* is the app's own (the iframe owns its url via `@lotics/app-sdk/router`), and the wrapper bakes the saved screen (`_loc`) into the iframe src on load so a refresh restores it, mirroring production); `agentRun` (streaming) is proxied through `POST /_agent_run`, which opens the run's SSE with the CLI key and pipes chunks back to the iframe (`stream-chunk`* → `stream-end`), so `useAgentRun` works in the dev loop just like production; `context` resolves the viewer (`member_id` from `cli/whoami` + `comments_enabled` from the local manifest) and fetches the installation's stored `config` live from the app row, so `useConfig()` renders the same values as production. `--view-as` (global flag; also `LOTICS_VIEW_AS`) threads `x-view-as-member-id` so `is_current_member` + `context` resolve to that member — **admin key only** (the server 403s a non-admin), writes stay attributed to the key owner. Hot reload via Vite; full DevTools / Playwright access via plain localhost. **Every forwarded op logs one line naming its ALIAS** — `[rpc] query applicants 231ms` — and `query applicants (count)` for a count request, which is a SECOND full execution of the same query rather than a cheap lookup. When requests overlap the line carries `· N in flight`. That number is the one to watch: the server bounds how many app queries run at once, so requests past the bound wait and the wait lands inside each request's own duration — a burst reads as "every query got slower", which looks like a slow database and is not one. A screen firing its list plus three facet counts on one keystroke shows up here as eight lines over one or two aliases; see `@lotics/app-sdk` `docs/data_fetching.md` (`useCount`, and handing `usePaginatedQuery` a `total`) and `docs/queries.md` §10 for collapsing them. **Holds no realtime connection** — push belongs to the product frontend, so an app previewed here never updates on an external write (a CLI run, another tab, an agent): reload to see it. Deliberate rather than missing, since the alternative is a second implementation of the channel in the wrapper page, and a blanket poll here would hide an app whose queries do not declare their tables — the one mistake the real host punishes. The startup banner says `realtime: off` so this is visible without reading this table. The dev-optimizer pre-bundle list (`optimizeDeps.include`, load-bearing for dev) is imported from `@lotics/ui/vite` (`loticsOptimizeDeps`) rather than hardcoded in the scaffold, so it tracks the installed `@lotics/ui` and can never go stale. Binds **loopback only** (`127.0.0.1`) — `/_rpc` dispatches with the developer's API key, so a socket on every interface would hand anyone on the network full read/write on the workspace. |
61
61
  | `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. |
62
- | `lotics xlsx <subcmd>` | Local .xlsx read/write/edit using the bundled `@lotics/xlsx` engine (no auth, no network). 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). |
63
- | `lotics docx <subcmd>` | Local .docx read/write/edit using the bundled `@lotics/docx` engine (OOXML round-trip surface only — no ProseMirror baggage). 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. |
62
+ | `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). |
63
+ | `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. |
64
64
  | `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). |
65
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.180.0",
3
+ "version": "0.181.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {