@lotics/cli 0.154.0 → 0.156.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/AGENTS.md CHANGED
@@ -39,14 +39,21 @@ whether something exists, read `lotics --help` § COMMANDS — the whole section
39
39
  from a shell serving many. Every command echoes its resolved target to **stderr** (`lotics → <org> /
40
40
  <workspace>`); read it back before trusting a write. Resolution precedence is in README § Organizations.
41
41
  - **Large payloads bypass `ARG_MAX`** — `lotics run <tool> @args.json` or piped stdin. A leading `@` is
42
- unambiguously a file path (JSON args start with `{`). The same ingest applies to `app workflow run`
43
- and `app agent run`.
42
+ unambiguously a file path (JSON args start with `{`).
44
43
  - **stdout is the payload, stderr is the narration.** Progress, status lines, and the target echo go to
45
44
  stderr; the result goes to stdout, so piping stays clean. `--json` switches stdout from the
46
45
  agent-readable summary to the full structured object.
47
- - **Exit codes are assertable.** A command that runs remote work exits non-zero when that work failed —
48
- `app workflow run` on `status:"error"`, `app agent run` on any settled status other than `completed`,
49
- `workspace doctor` on findings. Scripts can gate on them.
46
+ - **Every tool is invoked one way — `lotics run <tool>`.** Including the ones that RUN something
47
+ (`run_app_workflow`, `run_app_agent`, `run_app_query`). An `app` command exists only for work no
48
+ tool call can do: scaffold, build, typecheck, serve, or read and push a local file.
49
+ - **Exit codes are assertable, and they report the WORK rather than the call.** `lotics run` exits
50
+ non-zero when the result's own envelope carries a failed `status` (`error`/`failed`/`cancelled`),
51
+ so `lotics run … && next-step` cannot walk past a refused run; `workspace doctor` exits non-zero on
52
+ findings. An unrecognized status exits 0 — the list is an allowlist of failure, so a status added
53
+ later never turns a working script red — and a parked run (`awaiting_input`) is not a failure.
54
+ - **`--print-created` / `--cleanup` on any call that reports `side_effects`.** The first prints the
55
+ records created plus a paste-ready cleanup plan and what cannot be auto-undone; the second runs
56
+ those deletes (records only — never files, integrations or notifications). Neither is a rollback.
50
57
  - **Text output is the default and is built for reading**; reach for `--json` only when a field is
51
58
  needed programmatically.
52
59
  - **`lotics report '<json>'` is the channel for what nothing else records.** Reach for it
package/README.md CHANGED
@@ -279,13 +279,14 @@ lotics app codegen # import { F, OPT } from "../.lotics/
279
279
  # query drift. Exits 1 on what a deploy refuses, so CI can gate on it.
280
280
  lotics app check
281
281
 
282
- # Execute a bound app workflow end-to-end (inputs: inline / @file / stdin)
283
- lotics app workflow run issueInvoice '{"record_id":"rec_..."}'
284
- cat inputs.json | lotics app workflow run importRates # bulk inputs bypass ARG_MAX
282
+ # Running an app's alias is a TOOL call, like every other tool. Exits non-zero
283
+ # when the RUN failed, not only when the call did.
284
+ lotics run run_app_workflow '{"app_id":"app_...","alias":"issueInvoice","inputs":{"record_id":"rec_..."}}'
285
+ cat args.json | lotics run run_app_workflow # bulk inputs bypass ARG_MAX
285
286
  # Honest post-run harvest: created records + a paste-ready cleanup plan + the
286
287
  # caveat (external/notification calls can't be auto-undone; sub-workflows may run).
287
- lotics app workflow run issueInvoice '{...}' --print-created
288
- lotics app workflow run issueInvoice '{...}' --cleanup # also deletes created records (NOT a rollback)
288
+ lotics run run_app_workflow '{...}' --print-created
289
+ lotics run run_app_workflow '{...}' --cleanup # also deletes created records (NOT a rollback)
289
290
 
290
291
  # Edit workflow bodies as files. `app pull` writes src/workflows/<alias>.ts (the
291
292
  # faithful server source, wrapped + referencing its .lotics/workflows/<alias>.globals.d.ts);
@@ -305,10 +306,11 @@ lotics app query set openInvoices # push package.json#lotics.queries.o
305
306
  # Run a bound app agent end-to-end (no deployed UI needed — app row + declaration
306
307
  # + member auth). Streams progress to stderr; reports the SETTLED run (structured
307
308
  # output / final text) to stdout; exits 0 only when the run completed.
308
- lotics app agent run app_abc recognize '{"image_file_id":"fil_..."}'
309
- cat input.json | lotics app agent run app_abc recognize # inputs via stdin/@file
310
- lotics app agent run app_abc recognize --json # full run summary to stdout
311
- lotics app agent run app_abc recognize --session cli-123 '{}' # continue an existing thread
309
+ lotics run run_app_agent '{"app_id":"app_abc","alias":"recognize","input":{"image_file_id":"fil_..."}}'
310
+ cat input.json | lotics run run_app_agent # inputs via stdin/@file
311
+ lotics run run_app_agent '{...}' --json # full run summary to stdout
312
+ # A run that outlives the call's bounded wait keeps going server-side; read it with
313
+ lotics run get_app_agent_run '{"app_id":"app_abc","run_id":"run_..."}'
312
314
 
313
315
  # Dev-link @lotics/ui to a monorepo checkout for ONE command — nothing is written to disk
314
316
  LOTICS_UI_SRC=/abs/monorepo/packages/ui/src lotics app dev
package/dist/src/cli.js CHANGED
@@ -45746,6 +45746,29 @@ function describeCliError(error52) {
45746
45746
  return error52.message;
45747
45747
  }
45748
45748
 
45749
+ // src/tool_result.ts
45750
+ var RUN_TOOLS = /* @__PURE__ */ new Set(["run_app_workflow", "run_app_agent"]);
45751
+ var FAILED_STATUSES = /* @__PURE__ */ new Set(["error", "failed", "cancelled", "canceled"]);
45752
+ function envelope(result) {
45753
+ if (typeof result !== "object" || result === null || Array.isArray(result)) return null;
45754
+ return result;
45755
+ }
45756
+ function resultReportsFailure(toolName, result) {
45757
+ if (!RUN_TOOLS.has(toolName)) return false;
45758
+ const { status } = envelope(result) ?? {};
45759
+ return typeof status === "string" && FAILED_STATUSES.has(status);
45760
+ }
45761
+ function resultSummary(result) {
45762
+ const { status, message: message2 } = envelope(result) ?? {};
45763
+ if (typeof status !== "string") return null;
45764
+ return typeof message2 === "string" && message2 !== "" ? `${status}: ${message2}` : status;
45765
+ }
45766
+ function resultSideEffects(result) {
45767
+ const found = envelope(result)?.side_effects;
45768
+ if (typeof found !== "object" || found === null || Array.isArray(found)) return void 0;
45769
+ return found;
45770
+ }
45771
+
45749
45772
  // src/version.ts
45750
45773
  import { readFileSync } from "node:fs";
45751
45774
  import { fileURLToPath } from "node:url";
@@ -45963,7 +45986,14 @@ var COMMANDS = [
45963
45986
  help: [
45964
45987
  " lotics run <tool> '<json>' Execute a tool",
45965
45988
  " lotics run <tool> @args.json Read JSON args from a file (large payloads)",
45966
- " cat args.json | lotics run <tool> Read JSON args from stdin (large payloads)"
45989
+ " cat args.json | lotics run <tool> Read JSON args from stdin (large payloads)",
45990
+ " Exits non-zero when the RESULT reports the work",
45991
+ " failed (a refused workflow, a failed agent run) \u2014",
45992
+ " not only when the call did.",
45993
+ " lotics run <tool> --print-created Report the records the call created + a",
45994
+ " paste-ready cleanup plan + what cannot be undone",
45995
+ " lotics run <tool> --cleanup Also delete those records \u2014 records ONLY,",
45996
+ " never files/integrations/notifications. NOT a rollback"
45967
45997
  ]
45968
45998
  },
45969
45999
  {
@@ -45976,7 +46006,7 @@ var COMMANDS = [
45976
46006
  " carries code + queries only \u2014 workflow bindings are",
45977
46007
  " managed by set_app_workflow / remove_app_workflow.",
45978
46008
  " --prune also UNBINDS aliases this bundle no longer",
45979
- " names; off by default because `app workflow run` and",
46009
+ " names; off by default because `run_app_workflow` and",
45980
46010
  " chat reach a binding the bundle never calls)",
45981
46011
  " lotics app versions [app_id] Show deploy history newest-first (version,",
45982
46012
  " timestamp, deployer, build status, -m message;",
@@ -45989,11 +46019,6 @@ var COMMANDS = [
45989
46019
  " the code calls but nothing bound, undeclared",
45990
46020
  " capabilities, query drift. Exits 1 on what a",
45991
46021
  " deploy would refuse, so CI can gate on it",
45992
- " lotics app workflow run <alias> '<json>' Execute a bound app workflow end-to-end",
45993
- " (inputs: inline JSON, @file, or stdin;",
45994
- " --print-created reports created records +",
45995
- " a paste-ready cleanup plan; --cleanup also",
45996
- " deletes those records \u2014 NOT a rollback)",
45997
46022
  " lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body",
45998
46023
  " through set_app_workflow (server verifies)",
45999
46024
  " lotics app workflow pull Rewrite src/workflows/*.ts from the server",
@@ -46002,10 +46027,6 @@ var COMMANDS = [
46002
46027
  " lotics app query set <alias> Push package.json#lotics.queries.<alias> to",
46003
46028
  " apps.queries via set_app_query (no deploy;",
46004
46029
  " re-synced by the next deploy from the manifest)",
46005
- " lotics app agent run <app_id> <alias> '<json>' Run a bound app agent end-to-end",
46006
- " (inputs: inline JSON, @file, or stdin; streams",
46007
- " progress to stderr, reports the settled run;",
46008
- " --session <id> continues a thread; --json)",
46009
46030
  " lotics app agent set <alias> Push the edited src/agents/<alias>.md instructions,",
46010
46031
  " and ONLY those \u2014 the server merges, so the typed",
46011
46032
  " fields keep whatever is bound (a manifest is a",
@@ -46187,7 +46208,7 @@ async function reportCommand(client, options) {
46187
46208
  import fs8 from "node:fs";
46188
46209
  import path9 from "node:path";
46189
46210
  import { spawn as spawn2 } from "node:child_process";
46190
- import { createHash as createHash3, randomUUID } from "node:crypto";
46211
+ import { createHash as createHash3 } from "node:crypto";
46191
46212
  import { tmpdir } from "node:os";
46192
46213
 
46193
46214
  // src/starter_template.ts
@@ -66153,6 +66174,33 @@ var cForStepSchema = zod_default.lazy(
66153
66174
  );
66154
66175
  var workflowStepsSchema = zod_default.array(workflowStepSchema).min(1);
66155
66176
 
66177
+ // ../shared/src/suggest_tool_name.ts
66178
+ var VERB_ALIASES = {
66179
+ list: "query",
66180
+ find: "query",
66181
+ search: "query",
66182
+ fetch: "get",
66183
+ read: "get",
66184
+ add: "create",
66185
+ insert: "create",
66186
+ edit: "update",
66187
+ set: "update",
66188
+ remove: "delete"
66189
+ };
66190
+ function tokensOf(name2) {
66191
+ return name2.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t !== "").map((t) => VERB_ALIASES[t] ?? t);
66192
+ }
66193
+ function suggestToolNames(wanted, candidates, limit = 3) {
66194
+ const parts = tokensOf(wanted);
66195
+ if (parts.length === 0) return [];
66196
+ const noun = parts[parts.length - 1];
66197
+ return [...candidates].map((candidate) => {
66198
+ const tokens = tokensOf(candidate);
66199
+ const shared = tokens.filter((t) => parts.includes(t)).length;
66200
+ return { candidate, score: shared + (tokens.includes(noun) ? 2 : 0), size: tokens.length };
66201
+ }).filter((c) => c.score > 0).sort((a, b) => b.score - a.score || a.size - b.size || a.candidate.localeCompare(b.candidate)).slice(0, limit).map((c) => c.candidate);
66202
+ }
66203
+
66156
66204
  // ../shared/src/zod_issues.ts
66157
66205
  function formatZodIssues(issues) {
66158
66206
  return issues.map((i2) => `${i2.path.join(".") || "<root>"}: ${i2.message}`).join("; ");
@@ -69438,6 +69486,10 @@ function describeSyntaxError(source, raw, parseWith) {
69438
69486
 
69439
69487
  This body parses as TypeScript but not as JavaScript \u2014 a workflow body is a JS subset, so type syntax (a parameter annotation, \`as\`, a generic) is not allowed. Remove it. If the type pass then reports an implicit \`any\`, give the VALUE a known type instead \u2014 read it from a typed source (a tool result, \`trigger.app_workflow.inputs\`) rather than annotating the parameter.`;
69440
69488
  }
69489
+ function unknownToolMessage(name2, toolNames) {
69490
+ const suggestions = toolNames ? suggestToolNames(name2, toolNames) : [];
69491
+ return suggestions.length > 0 ? `Unknown tool "${name2}". Did you mean: ${suggestions.join(", ")}?` : `Unknown tool "${name2}".`;
69492
+ }
69441
69493
  function parseWorkflowJs(source, opts) {
69442
69494
  const tooLarge = checkSourceLength(source);
69443
69495
  if (tooLarge) return tooLarge;
@@ -69949,7 +70001,7 @@ function walkExpressionStatement(stmt, scope, stepIds, opts, stepId, description
69949
70001
  input
69950
70002
  };
69951
70003
  }
69952
- fail(callee, `Unknown tool "${name2}". Tools must be registered.`);
70004
+ fail(callee, unknownToolMessage(name2, opts.toolNames) + " Tools must be registered.");
69953
70005
  }
69954
70006
  if (e.type === "CallExpression" && e.callee.type === "MemberExpression" && !e.callee.computed && e.callee.property.type === "Identifier" && e.callee.property.name === "push") {
69955
70007
  return walkPushStatement(e, scope, stepId, description);
@@ -70610,7 +70662,7 @@ function walkToolInvocation(call, scope, opts, allowWaits) {
70610
70662
  fail(call, `${name2} is a wait step, not a tool. Use "await ${name2}({...})" as a standalone statement.`);
70611
70663
  }
70612
70664
  if (opts.toolNames && !opts.toolNames.has(name2)) {
70613
- fail(callee, `Unknown tool "${name2}".`);
70665
+ fail(callee, unknownToolMessage(name2, opts.toolNames));
70614
70666
  }
70615
70667
  if (call.arguments.length !== 1) {
70616
70668
  fail(call, `${name2} requires exactly one options-object argument.`);
@@ -72307,7 +72359,15 @@ function isMissingWorkflowBodyError(error52) {
72307
72359
  }
72308
72360
  function workflowFileHeader(alias) {
72309
72361
  const refPath = path9.join("..", "..", WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`).split(path9.sep).join("/");
72310
- return `/// <reference path="${refPath}" />
72362
+ return (
72363
+ // The scaffold ships oxlint, whose `triple-slash-reference` rule flags the
72364
+ // line below — so every pulled body arrived with a warning its author could
72365
+ // not act on: the reference is what types the body against the workspace, so
72366
+ // deleting it breaks `workflow check`. The disable rides the FILE rather than
72367
+ // sitting in the project's lint config, because a config entry is scoped to
72368
+ // where the file currently lives and stops covering it the moment it moves.
72369
+ `// oxlint-disable-next-line typescript-eslint/triple-slash-reference
72370
+ /// <reference path="${refPath}" />
72311
72371
  // Auto-pulled workflow body for "${alias}". Edit the BODY between the wrapper
72312
72372
  // lines below, then check + push with:
72313
72373
  // lotics app workflow check ${alias}
@@ -72317,7 +72377,8 @@ function workflowFileHeader(alias) {
72317
72377
  // set) \u2014 they only make the body typecheck locally against the workspace types.
72318
72378
  // Do NOT rename this file \u2014 the filename is the alias the binding is keyed by.
72319
72379
  export {};
72320
- `;
72380
+ `
72381
+ );
72321
72382
  }
72322
72383
  function workflowFilePath(projectDir, alias) {
72323
72384
  return path9.join(projectDir, WORKFLOWS_DIR, `${alias}.ts`);
@@ -72390,13 +72451,13 @@ ${dts.replace(/\s+$/, "")}
72390
72451
  function normalizeWorkflowBody(source) {
72391
72452
  return source.replace(/\s+$/, "");
72392
72453
  }
72393
- function writeWorkflowFile(projectDir, alias, source, envelope = { prefix: WORKFLOW_ENVELOPE_PREFIX, suffix: WORKFLOW_ENVELOPE_SUFFIX }) {
72454
+ function writeWorkflowFile(projectDir, alias, source, envelope2 = { prefix: WORKFLOW_ENVELOPE_PREFIX, suffix: WORKFLOW_ENVELOPE_SUFFIX }) {
72394
72455
  const dir = path9.join(projectDir, WORKFLOWS_DIR);
72395
72456
  fs8.mkdirSync(dir, { recursive: true });
72396
72457
  const file2 = workflowFilePath(projectDir, alias);
72397
72458
  const body = normalizeWorkflowBody(source);
72398
72459
  fs8.writeFileSync(file2, `${workflowFileHeader(alias)}
72399
- ${envelope.prefix}${body}${envelope.suffix}
72460
+ ${envelope2.prefix}${body}${envelope2.suffix}
72400
72461
  `);
72401
72462
  return file2;
72402
72463
  }
@@ -72472,7 +72533,7 @@ async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows
72472
72533
  );
72473
72534
  continue;
72474
72535
  }
72475
- const envelope = await fetchWorkflowGlobals(
72536
+ const envelope2 = await fetchWorkflowGlobals(
72476
72537
  client,
72477
72538
  projectDir,
72478
72539
  app_id,
@@ -72483,7 +72544,7 @@ async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows
72483
72544
  opts.kept.push(`src/workflows/${alias}.ts`);
72484
72545
  continue;
72485
72546
  }
72486
- writeWorkflowFile(projectDir, alias, source, envelope);
72547
+ writeWorkflowFile(projectDir, alias, source, envelope2);
72487
72548
  const rowDescriptionSeen = res.result.description;
72488
72549
  writeSynced(projectDir, "workflows", alias, {
72489
72550
  content: contentSha(normalizeWorkflowBody(source)),
@@ -72935,8 +72996,8 @@ async function refreshWorkflowTypes(client, projectDir, app_id, alias, declarati
72935
72996
  if (!fs8.existsSync(file2)) return false;
72936
72997
  const body = stripWorkflowHeader(fs8.readFileSync(file2, "utf-8"));
72937
72998
  if (body.trim() === "") return false;
72938
- const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias, declaration);
72939
- writeWorkflowFile(projectDir, alias, body, envelope);
72999
+ const envelope2 = await fetchWorkflowGlobals(client, projectDir, app_id, alias, declaration);
73000
+ writeWorkflowFile(projectDir, alias, body, envelope2);
72940
73001
  return true;
72941
73002
  }
72942
73003
  function readPriorStamp(projectDir) {
@@ -73673,7 +73734,7 @@ function warnAboutOrphanedBindings(called, live) {
73673
73734
  sites literal to prune them.` : `
73674
73735
 
73675
73736
  Nothing is removed automatically: a binding is also reachable by
73676
- \`lotics app workflow run\` and from chat, neither of which leaves a call
73737
+ \`lotics run run_app_workflow\` and chat, neither of which leaves a call
73677
73738
  site to find. If these really are dead: lotics app deploy --prune`)
73678
73739
  );
73679
73740
  return lines.length;
@@ -74105,118 +74166,11 @@ async function cleanupCreatedRecords(client, created) {
74105
74166
  }
74106
74167
  return allDeleted;
74107
74168
  }
74108
- async function appExecuteWorkflow(client, args) {
74109
- const meta3 = readAppMeta(process.cwd());
74110
- const result = await client.appWorkflow(meta3.app_id, args.alias, args.inputs);
74111
- console.log(JSON.stringify(result, null, 2));
74112
- const status = typeof result.status === "string" ? result.status : "unknown";
74113
- const message2 = typeof result.message === "string" ? result.message : "";
74114
- console.error(`Workflow "${args.alias}" \u2192 ${status}${message2 ? `: ${message2}` : ""}`);
74115
- let cleanupFailed = false;
74116
- if ((args.printCreated || args.cleanup) && result.side_effects) {
74117
- printSideEffects(result.side_effects);
74118
- if (args.cleanup) {
74119
- const allDeleted = await cleanupCreatedRecords(
74120
- client,
74121
- parseCreatedRecords(result.side_effects.created_records)
74122
- );
74123
- cleanupFailed = !allDeleted;
74124
- }
74125
- } else if (args.printCreated || args.cleanup) {
74126
- console.error("\n(no side-effect summary returned by the server)");
74127
- }
74128
- if (status === "error" || cleanupFailed) process.exit(1);
74129
- }
74130
- async function streamAgentTextDeltas(body, onText) {
74131
- const reader = body.getReader();
74132
- const decoder = new TextDecoder();
74133
- let buffer = "";
74134
- let accumulated = "";
74135
- try {
74136
- for (; ; ) {
74137
- const { value: value2, done } = await reader.read();
74138
- if (done) break;
74139
- buffer += decoder.decode(value2, { stream: true });
74140
- const frames = buffer.split("\n\n");
74141
- buffer = frames.pop() ?? "";
74142
- for (const frame of frames) {
74143
- for (const line of frame.split("\n")) {
74144
- if (!line.startsWith("data:")) continue;
74145
- const payload = line.slice(5).trim();
74146
- if (!payload || payload === "[DONE]") continue;
74147
- let chunk;
74148
- try {
74149
- chunk = JSON.parse(payload);
74150
- } catch {
74151
- continue;
74152
- }
74153
- if (chunk.type === "text-delta" && chunk.delta) {
74154
- accumulated += chunk.delta;
74155
- onText(chunk.delta);
74156
- }
74157
- }
74158
- }
74159
- }
74160
- } catch {
74161
- } finally {
74162
- reader.releaseLock();
74163
- }
74164
- return accumulated;
74165
- }
74166
74169
  var AGENT_RUN_POLL = {
74167
74170
  intervalMs: 1e3,
74168
74171
  settleTimeoutMs: 21 * 60 * 1e3,
74169
74172
  existenceTimeoutMs: 5e3
74170
74173
  };
74171
- var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
74172
- async function fetchSettledAgentRun(client, appId, sessionId2, runId, timing) {
74173
- const settleDeadline = Date.now() + timing.settleTimeoutMs;
74174
- const existenceDeadline = Date.now() + timing.existenceTimeoutMs;
74175
- for (; ; ) {
74176
- const { runs } = await client.listAgentRuns(appId, sessionId2);
74177
- const target = runId ? runs.find((r) => r.id === runId) : runs[runs.length - 1];
74178
- if (target) {
74179
- if (target.status !== "running" || Date.now() >= settleDeadline) return target;
74180
- } else if (Date.now() >= existenceDeadline) {
74181
- return void 0;
74182
- }
74183
- await sleep(timing.intervalMs);
74184
- }
74185
- }
74186
- async function appAgentRun(client, args, timing = AGENT_RUN_POLL) {
74187
- const sessionId2 = args.sessionId ?? `cli-${randomUUID()}`;
74188
- const continuing = args.sessionId !== void 0;
74189
- const res = await client.appAgentRunStream(args.app_id, args.alias, {
74190
- session_id: sessionId2,
74191
- input: args.input
74192
- });
74193
- const runId = res.headers.get("x-app-agent-run-id") ?? void 0;
74194
- if (res.body) {
74195
- await streamAgentTextDeltas(res.body, (piece) => process.stderr.write(piece));
74196
- }
74197
- const run = await fetchSettledAgentRun(client, args.app_id, sessionId2, runId, timing);
74198
- if (!run) {
74199
- console.error(`
74200
- Could not find the settled run for session ${sessionId2} on ${args.app_id}.`);
74201
- console.error("The run may still be in progress \u2014 re-check with `lotics run` against the app's agent-runs.");
74202
- process.exit(1);
74203
- }
74204
- if (args.json) {
74205
- console.log(JSON.stringify(run, null, 2));
74206
- } else if (run.output !== null && typeof run.output === "object") {
74207
- console.log(JSON.stringify(run.output, null, 2));
74208
- } else if (typeof run.output === "string") {
74209
- console.log(run.output);
74210
- }
74211
- console.error(
74212
- `
74213
- Agent "${args.alias}" run ${run.id} \u2192 ${run.status}${run.error_message ? `: ${run.error_message}` : ""}`
74214
- );
74215
- console.error(
74216
- continuing ? `Session: ${sessionId2}` : `Session: ${sessionId2} (fresh \u2014 pass --session ${sessionId2} to continue this thread)`
74217
- );
74218
- if (run.status !== "completed") process.exit(1);
74219
- }
74220
74174
  async function appAgentSet(client, args) {
74221
74175
  const projectDir = args.projectDir ?? process.cwd();
74222
74176
  const meta3 = readAppMeta(projectDir);
@@ -103030,7 +102984,7 @@ import { readFileSync as readFileSync2, writeFileSync, existsSync, mkdtempSync,
103030
102984
  import { tmpdir as tmpdir2 } from "node:os";
103031
102985
  import { join as join2, dirname, resolve, extname, basename } from "node:path";
103032
102986
  import { fileURLToPath as fileURLToPath3 } from "node:url";
103033
- import { setTimeout as sleep2 } from "node:timers/promises";
102987
+ import { setTimeout as sleep } from "node:timers/promises";
103034
102988
  var HERE = dirname(fileURLToPath3(import.meta.url));
103035
102989
  function fail3(msg) {
103036
102990
  console.error(msg);
@@ -103162,7 +103116,7 @@ async function runPreviewCommand(filePath, flags) {
103162
103116
  const p = parseInt(readFileSync2(portFile, "utf8").split("\n")[0], 10);
103163
103117
  if (p) cdpPort = p;
103164
103118
  }
103165
- if (!cdpPort) await sleep2(100);
103119
+ if (!cdpPort) await sleep(100);
103166
103120
  }
103167
103121
  if (!cdpPort) throw new Error("Chrome did not expose a debugging port (launch failed?).");
103168
103122
  let target;
@@ -103172,7 +103126,7 @@ async function runPreviewCommand(filePath, flags) {
103172
103126
  target = list2.find((t) => t.type === "page");
103173
103127
  } catch {
103174
103128
  }
103175
- if (!target?.webSocketDebuggerUrl) await sleep2(100);
103129
+ if (!target?.webSocketDebuggerUrl) await sleep(100);
103176
103130
  }
103177
103131
  if (!target?.webSocketDebuggerUrl) throw new Error("No Chrome page target available.");
103178
103132
  const cdp = await cdpConnect(target.webSocketDebuggerUrl);
@@ -103194,7 +103148,7 @@ async function runPreviewCommand(filePath, flags) {
103194
103148
  if (v.warnings?.length) warnings.push(...v.warnings);
103195
103149
  break;
103196
103150
  }
103197
- await sleep2(75);
103151
+ await sleep(75);
103198
103152
  }
103199
103153
  if (!done) throw new Error("Render timed out (page never signaled completion).");
103200
103154
  if (err2) throw new Error(`Render engine error: ${err2}`);
@@ -103860,12 +103814,10 @@ async function main() {
103860
103814
  console.error(" lotics app versions [app_id] Show deploy history (version, when, who, message)");
103861
103815
  console.error(" lotics app codegen [path] Regenerate .lotics/* (types + field ids) \u2014 no deploy");
103862
103816
  console.error(" lotics app check Deploy's pre-flight without the build (exits 1 on a blocker)");
103863
- console.error(" lotics app workflow run <alias> '<json>' Execute a bound app workflow end-to-end");
103864
103817
  console.error(" lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body");
103865
103818
  console.error(" lotics app workflow pull Rewrite src/workflows/*.ts from the server");
103866
103819
  console.error(" lotics app workflow check [alias] Typecheck src/workflows bodies locally");
103867
103820
  console.error(" lotics app query set <alias> Push lotics.queries.<alias> to apps.queries (no deploy)");
103868
- console.error(" lotics app agent run <app_id> <alias> '<json>' Run a bound app agent (streams progress, reports the settled run)");
103869
103821
  console.error(" lotics app subdomain <new-subdomain> Rename the app's public <slug>.lotics.app address");
103870
103822
  console.error(` lotics app rename "<new name>" Rename the app's display name (launcher title)`);
103871
103823
  console.error(" lotics app dev [path] Run the app locally with HMR + RPC forwarding");
@@ -104115,40 +104067,11 @@ Available workspaces:`);
104115
104067
  const action = toolArgs;
104116
104068
  const workflowUsage = () => {
104117
104069
  console.error("Usage:");
104118
- console.error(" lotics app workflow run <alias> '<json>' Execute a bound app workflow");
104119
104070
  console.error(" lotics app workflow set <alias> Push src/workflows/<alias>.ts");
104120
104071
  console.error(" lotics app workflow pull Rewrite src/workflows/*.ts from the server");
104121
104072
  console.error(" lotics app workflow check [alias] Typecheck src/workflows bodies locally");
104122
104073
  process.exit(1);
104123
104074
  };
104124
- if (action === "run") {
104125
- const alias = restArgs[0];
104126
- if (!alias) {
104127
- console.error("Usage: lotics app workflow run <alias> '<json>'");
104128
- console.error(" lotics app workflow run <alias> @inputs.json (read inputs from a file)");
104129
- console.error(" cat inputs.json | lotics app workflow run <alias> (read inputs from stdin)");
104130
- console.error("Flags: --print-created (report created records + cleanup plan + caveat)");
104131
- console.error(" --cleanup (also delete the created records \u2014 records only, NOT a rollback)");
104132
- process.exit(1);
104133
- }
104134
- const ingested = await ingestJsonArgs({
104135
- rawArg: restArgs[1],
104136
- stdinIsTTY: process.stdin.isTTY ?? false,
104137
- readFile: (p) => fs13.readFileSync(p, "utf-8"),
104138
- readStdin
104139
- });
104140
- if (ingested.kind === "error") {
104141
- console.error(ingested.message);
104142
- process.exit(1);
104143
- }
104144
- await appExecuteWorkflow(client, {
104145
- alias,
104146
- inputs: ingested.args,
104147
- printCreated: flags.printCreated,
104148
- cleanup: flags.cleanup
104149
- });
104150
- return;
104151
- }
104152
104075
  if (action === "set") {
104153
104076
  const alias = restArgs[0];
104154
104077
  if (!alias) {
@@ -104168,39 +104091,10 @@ Available workspaces:`);
104168
104091
  if (subcommand === "agent") {
104169
104092
  const action = toolArgs;
104170
104093
  const agentUsage = () => {
104171
- console.error("Usage: lotics app agent run <app_id> <alias> ['<json>'|@inputs.json|stdin] [--session <id>] [--json]");
104172
- console.error(" cat inputs.json | lotics app agent run <app_id> <alias> (read inputs from stdin)");
104173
- console.error("Streams the run's progress to stderr; reports the settled run (structured output / text) to stdout.");
104174
- console.error("--session <id> continues an existing thread; omitted mints a fresh session per run.");
104175
- console.error("");
104176
- console.error(" lotics app agent set <alias> Push src/agents/<alias>.md back through set_app_agent");
104094
+ console.error("Usage: lotics app agent set <alias> Push src/agents/<alias>.md back through set_app_agent");
104095
+ console.error(` To RUN one: lotics run run_app_agent '{"app_id":...,"alias":...,"input":{}}'`);
104177
104096
  process.exit(1);
104178
104097
  };
104179
- if (action === "run") {
104180
- const appId = restArgs[0];
104181
- const alias = restArgs[1];
104182
- if (!appId || !alias) {
104183
- agentUsage();
104184
- }
104185
- const ingested = await ingestJsonArgs({
104186
- rawArg: restArgs[2],
104187
- stdinIsTTY: process.stdin.isTTY ?? false,
104188
- readFile: (p) => fs13.readFileSync(p, "utf-8"),
104189
- readStdin
104190
- });
104191
- if (ingested.kind === "error") {
104192
- console.error(ingested.message);
104193
- process.exit(1);
104194
- }
104195
- await appAgentRun(client, {
104196
- app_id: appId,
104197
- alias,
104198
- input: ingested.args,
104199
- sessionId: flags.session,
104200
- json: flags.json
104201
- });
104202
- return;
104203
- }
104204
104098
  if (action === "set") {
104205
104099
  const alias = restArgs[0];
104206
104100
  if (!alias) {
@@ -104387,6 +104281,25 @@ ${JSON.stringify(info.input_schema, null, 2)}`);
104387
104281
  } else {
104388
104282
  console.log(JSON.stringify(result.result, null, 2));
104389
104283
  }
104284
+ const wantsHarvest = flags.printCreated || flags.cleanup;
104285
+ const sideEffects = resultSideEffects(result.result);
104286
+ let cleanupFailed = false;
104287
+ if (wantsHarvest && sideEffects) {
104288
+ printSideEffects(sideEffects);
104289
+ if (flags.cleanup) {
104290
+ cleanupFailed = !await cleanupCreatedRecords(
104291
+ client,
104292
+ parseCreatedRecords(sideEffects.created_records)
104293
+ );
104294
+ }
104295
+ } else if (wantsHarvest) {
104296
+ console.error("\n(this tool reported no side effects)");
104297
+ }
104298
+ if (resultReportsFailure(toolName, result.result)) {
104299
+ console.error(`${toolName} \u2192 ${resultSummary(result.result) ?? "failed"}`);
104300
+ process.exit(1);
104301
+ }
104302
+ if (cleanupFailed) process.exit(1);
104390
104303
  return;
104391
104304
  }
104392
104305
  }
@@ -22,6 +22,10 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
22
22
  | `lotics tools <name>` | Full description + JSON Schema for one tool |
23
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 `{`). |
24
24
  | `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |
25
+ | — | **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. |
26
+ | — | **The exit code reports the WORK, not just the call.** A result whose envelope carries a failed `status` (`error`/`failed`/`cancelled`) exits non-zero and prints `<tool> → <status>: <message>` to stderr, so `lotics run … && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE — an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. |
27
+ | `lotics run <tool> --print-created` | Report the records the call created, grouped by table, with a paste-ready `delete_records` per table and the mandatory caveat naming what cannot be auto-undone (external integrations, notifications, possible sub-workflows). Works for any tool that returns a `side_effects` block, not workflows alone. |
28
+ | `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes — harvested records **only**, never files / external calls / notifications. **Not a rollback**; a rollback is structurally impossible here. A partial cleanup exits non-zero so a script cannot read it as success. |
25
29
  | `lotics upload <file\|dir...>` · `--stdin` · `--base64` · `--url <url>` | Upload files/directories via multipart POST to /v1/files. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** — an attachment decoded in memory, a generated document, a signed download link — each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY — `Buffer.from(s, "base64")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |
26
30
  | `lotics file download <file_id> [-o <dir>]` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` → fetch the presigned URL, saving under the stored filename (from the response's `Content-Disposition`) into `-o` (a **DIRECTORY** — note this is distinct from `lotics file preview`'s `-o`, which is a FILE path), else cwd. `lotics file download record <record_id> <field_key>` pulls every file on a record's file field. |
27
31
  | `lotics knowledge list [--include-hidden]` | `GET /v1/knowledge_docs` — a table of id, name, tags, description (`--json` for the docs). **REST, not the `list_knowledge` tool**: the tool answers what the ASSISTANT may browse, and a hidden doc is out of that corpus by definition, so a tool-backed listing could never show one and the person who hid it would have no way back to it. Hidden docs are left out unless `--include-hidden` asks; those rows are marked `(hidden)`. |
@@ -33,7 +37,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
33
37
  | `lotics knowledge rm <id>` | Archive the doc via `delete_knowledge` (`{ knowledge_doc_id }`). The REST execute path does not gate `needsApproval`, so this runs unattended. |
34
38
  | `lotics app create <name> [path]` | Scaffold a Vite+React+TS custom-code app project; POST /v1/apps; npm install; vite build; upload as v1 |
35
39
  | `lotics app pull <app_id> [path]` | Download source archive from R2 (presigned), extract, npm install, stamp package.json's `lotics` field. With no `[path]`: refresh the cwd IN PLACE when it's already this app's own project (its manifest `app_id` matches — the documented `cd <app> && lotics app pull` flow), else clone into an `<name>/` subdir. **A pull never overwrites a file that differs from what it is about to write** — it writes only what is ABSENT or already identical, keeps the rest, and reports which files it kept plus the commands that close the gap. The same rule covers `src/workflows/<alias>.ts` and `src/agents/<alias>.md`, so an unpushed body or prompt survives too. Those two are written from the LIVE App row (`apps.workflows` / `apps.agents`), which owns them, and the archive's own copy of them is deliberately SKIPPED on extract: a deploy tars the whole source directory, so the tarball holds a deploy-time snapshot that is stale for anything authored since. The comparison is against the app's own content, not git, so it holds for a project that was never a repo. `--force` takes the app's copy and DISCARDS local edits; there is no other way to lose them. **When a pull ACROSS versions keeps files, the manifest is left on the OLDER of the two versions** — the tree is then part one and part the other, and claiming the newer would make `deploy`'s `prev_version_id` check pass and ship a half-and-half bundle. Older, not "the one it had": `--from-version` pulls a deliberately old revision, so the version it had is the NEWER side, and holding that would match what the server serves and let the old source ship. Either way a deploy from that tree is refused until you reconcile the listed files by hand and pull again, or take the app's copy with `--force`. The report says which version each side is on, because a kept file is your unshipped work when the project was already current and merely the OLD version when it was behind — and nothing in a byte comparison can tell those apart. **`--from-version <apv_…>`** pulls an OLDER revision instead of the current one (`lotics app versions` lists the ids) — point it at a NEW path to read a previous revision without disturbing the project you are in. The manifest records the version actually written, never the live pointer, so a deploy from that checkout is refused by the version guard rather than shipping old source over newer. — `workflows` and `agents` are sourced from the live App row (NOT the archived manifest), so `set_app_workflow` / `set_app_agent` authoring survives the pull. Regenerates `.lotics/app_{workflows,queries,agents}.d.ts` so `useWorkflow` / `useQuery` / `useAgentRun` stay typed, AND — AFTER `npm install`, so `node_modules/@lotics/ui` actually exists to read — `.lotics/react_native.d.ts` (the kit's web-only ViewStyle/TextStyle augmentation) and `.lotics/tsconfig.link.json`'s peer pins; a pulled project's own `tsc` used to fail the moment it used a component relying on the augmentation (Dialog, Picker, TimePicker, …) until `app codegen` was run by hand, because nothing had regenerated either one after install populated node_modules. AND the runtime `.lotics/app_fields.ts` (the same linked-vs-bespoke branch `app codegen` runs, off the app row already fetched — see that row for the two forms). That one is not optional: `app deploy` tars source with `--exclude=.lotics`, so no archive can carry it, and a pulled project whose `src/` imports `F`/`OPT` would fail to build with `Could not resolve "../../.lotics/app_fields"` until `app codegen` was run by hand. The write NAMES the form and the reason, because an in-place pull can FLIP a project between them (`opctl app publish` links an origin, `package eject` unlinks it) and that changes what the module does at load. Skipped under `--view-as` (the schema is read as that member and silently drops tables they cannot see — a narrowed `F` map compiles and then throws at runtime, worse than the missing module). A binding/schema fetch failure is non-fatal and names the right recovery for what is on disk: an existing file is kept, an ABSENT one warns about the build error and points at `app codegen`. Pull GENERATES but never RECONCILES `.lotics/` — deleting a companion whose alias the manifest no longer declares is `app codegen`'s alone, since pull's authority is the server's alias set and a declared-but-not-yet-`set` alias is supported. Also writes one `src/workflows/<alias>.ts` per bound workflow (faithful body from `get_app_workflow`) and one `src/agents/<alias>.md` per bound agent (its instructions, straight off the live row) — so the prose an author actually edits lives in a file, and pull always overwrites it from live, leaving no second copy to drift. A legacy workflow alias with no rendered source, or an agent with no instructions, warns and is skipped. The stamped `lotics.agents` map carries the TYPED half only (`inputs`/`outputs`/`tool_names`/`model_tier`/…) — an agent's prose lives solely in its `.md`, so there is never a second local copy to desync; a stale `instructions` left by an older CLI is inert and disappears on the next pull |
36
- | `lotics app deploy [--prune] -m <message>` | `-m` is OPTIONAL — omitted, the deploy derives the version message from what it actually pushed; pass `-m` when you have a reason worth recording. npm run build; tar source + dist; POST /v1/apps/{id}/versions multipart. **One command ships everything**: before the bundle moves, a deploy pushes every binding the project has ahead of the app — an edited workflow body or declaration, edited agent prose, a changed query — through `set_app_query`, then `set_app_workflow`, then `set_app_agent`, and fails the release if any push is refused. That order is required: an agent declares the query and workflow aliases it may call, so pushing it before its own new query is refused. A workflow's `description` is part of that push and is compared against the recorded baseline, not the live app — it lives on the workflow ROW, which `getApp` does not carry. It never AUTHORS a binding itself — those verbs stay the single writers — and each push carries the fingerprint the project last saw live (`lotics.synced`), so a stale checkout is refused rather than overwriting another author's edit. `package.json` means the same thing for both artifacts: editing `lotics.agents.<alias>.inputs`/`outputs` is pushed exactly like the workflow equivalent (only those two fields — `set_app_agent` merges, so everything the manifest does not model is left untouched). It also regenerates `.lotics/app_fields.ts` before building, since the build INLINES it and which form is correct follows from whether the app is a package installation — a deploy that skipped it could ship an origin's baked ids into every other install. `lotics app check` reports the same set without pushing; neither has a `--strict`. What the version RECORDS as the aliases it calls — the set `remove_app_workflow` / `remove_app_query` / `remove_app_agent` consult to refuse unbinding one the served version still reaches — is read by the SERVER out of the source archive this deploy uploads, not reported by the deploy. That matters because the deploy is also what unbinds: a client supplying the evidence used to refuse its own removal cannot be checked by it. After a successful deploy it warns about any alias the source CALLS that is NOT bound, and **names the inverse** — bindings the app still serves that this bundle mentions nowhere. It does NOT remove them: **`--prune` does, and only when passed.** A static scan sees the bundle's call sites and an agent's `query_aliases`/`workflow_aliases`; it cannot see `lotics app workflow run <alias>`, whose whole contract is that the alias is bound server-side, or chat's `run_app_workflow` under `app:use`. An operator-driven workflow therefore leaves no call site anywhere in the source and is indistinguishable from a dead one here — so a deploy that pruned by default deleted working tooling and printed it as a ✓. Pruning runs AFTER the version is live, because the removal tools refuse an alias the SERVED version still declares — so doing it first is refused by the guard that makes it safe. `--prune` is skipped ENTIRELY (with a warning, never a failure) when the source computes an alias at run time, since the scan cannot tell which binding that reaches and pruning "the rest" would be guessing with a deletion. Each removal prints the command that RESTORES it (`lotics app query set <alias>` / `app workflow set` / `app agent set`) on the same line as the ✓, because the act was always reversible and only ever failed to say so. A binding that will not unbind is reported and does NOT fail the release: the version is live and correct — and the server refuses to unbind a WORKFLOW this workspace has actually run (a recorded execution means a caller the source cannot name), which surfaces here as `✗ could not unbind …` with the date it last ran. After a successful deploy it also REFRESHES the `.lotics/workflows/<alias>.globals.d.ts` of any alias whose `// lotics:declaration` stamp says this deploy moved its declaration (only those — refreshing every bound alias would cost one round trip each on every deploy to fix something only ever wrong right after a manifest edit), from the manifest declaration, re-wrapping the SAME on-disk body (never re-fetching it, so local edits survive). A deploy is the moment the manifest becomes real, so it is also the moment the local types stop matching it — and the author's next act is usually `workflow set`, whose body would otherwise be typechecked against the declaration as it stood before this deploy. Non-fatal: the release already shipped, and stale types never fail it. |
40
+ | `lotics app deploy [--prune] -m <message>` | `-m` is OPTIONAL — omitted, the deploy derives the version message from what it actually pushed; pass `-m` when you have a reason worth recording. npm run build; tar source + dist; POST /v1/apps/{id}/versions multipart. **One command ships everything**: before the bundle moves, a deploy pushes every binding the project has ahead of the app — an edited workflow body or declaration, edited agent prose, a changed query — through `set_app_query`, then `set_app_workflow`, then `set_app_agent`, and fails the release if any push is refused. That order is required: an agent declares the query and workflow aliases it may call, so pushing it before its own new query is refused. A workflow's `description` is part of that push and is compared against the recorded baseline, not the live app — it lives on the workflow ROW, which `getApp` does not carry. It never AUTHORS a binding itself — those verbs stay the single writers — and each push carries the fingerprint the project last saw live (`lotics.synced`), so a stale checkout is refused rather than overwriting another author's edit. `package.json` means the same thing for both artifacts: editing `lotics.agents.<alias>.inputs`/`outputs` is pushed exactly like the workflow equivalent (only those two fields — `set_app_agent` merges, so everything the manifest does not model is left untouched). It also regenerates `.lotics/app_fields.ts` before building, since the build INLINES it and which form is correct follows from whether the app is a package installation — a deploy that skipped it could ship an origin's baked ids into every other install. `lotics app check` reports the same set without pushing; neither has a `--strict`. What the version RECORDS as the aliases it calls — the set `remove_app_workflow` / `remove_app_query` / `remove_app_agent` consult to refuse unbinding one the served version still reaches — is read by the SERVER out of the source archive this deploy uploads, not reported by the deploy. That matters because the deploy is also what unbinds: a client supplying the evidence used to refuse its own removal cannot be checked by it. After a successful deploy it warns about any alias the source CALLS that is NOT bound, and **names the inverse** — bindings the app still serves that this bundle mentions nowhere. It does NOT remove them: **`--prune` does, and only when passed.** A static scan sees the bundle's call sites and an agent's `query_aliases`/`workflow_aliases`; it cannot see `lotics run run_app_workflow`, whose whole contract is that the alias is bound server-side, or chat's call under `app:use`. An operator-driven workflow therefore leaves no call site anywhere in the source and is indistinguishable from a dead one here — so a deploy that pruned by default deleted working tooling and printed it as a ✓. Pruning runs AFTER the version is live, because the removal tools refuse an alias the SERVED version still declares — so doing it first is refused by the guard that makes it safe. `--prune` is skipped ENTIRELY (with a warning, never a failure) when the source computes an alias at run time, since the scan cannot tell which binding that reaches and pruning "the rest" would be guessing with a deletion. Each removal prints the command that RESTORES it (`lotics app query set <alias>` / `app workflow set` / `app agent set`) on the same line as the ✓, because the act was always reversible and only ever failed to say so. A binding that will not unbind is reported and does NOT fail the release: the version is live and correct — and the server refuses to unbind a WORKFLOW this workspace has actually run (a recorded execution means a caller the source cannot name), which surfaces here as `✗ could not unbind …` with the date it last ran. After a successful deploy it also REFRESHES the `.lotics/workflows/<alias>.globals.d.ts` of any alias whose `// lotics:declaration` stamp says this deploy moved its declaration (only those — refreshing every bound alias would cost one round trip each on every deploy to fix something only ever wrong right after a manifest edit), from the manifest declaration, re-wrapping the SAME on-disk body (never re-fetching it, so local edits survive). A deploy is the moment the manifest becomes real, so it is also the moment the local types stop matching it — and the author's next act is usually `workflow set`, whose body would otherwise be typechecked against the declaration as it stood before this deploy. Non-fatal: the release already shipped, and stale types never fail it. |
37
41
  | `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). |
38
42
  | `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` — **branched on whether the app is a package installation** (`getApp().package_id` set, from `generate_package_fields.ts`): a **linked/published** app emits the BINDING form (`F`/`OPT`/`ROLE` resolved from the installation's LIVE binding — via `appBinding` / the `binding` RPC — at module load through `getAppBinding()` + top-level await, so the source stays portable across every install); a **bespoke** app emits the BAKED form (`generate_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). Both forms share the `F`/`OPT` shape (contract aliases derive from the same slugified display names), so a published origin's deployed source compiles unchanged. Writing the BINDING form also heals the project's vitest setup: the binding form awaits `getAppBinding()` (a network call) at module load, so without a stub `npm test` fails to collect any test that imports the app graph — the heal writes `vitest.setup.ts` (mocks only `getAppBinding`, returning an echo binding: any alias → a self-identifying `fld:test:…`/`opt:test:…`/`grp:test:…` id) if absent, and warns the one-liner to add to `vite.config.ts`'s `test.setupFiles` if the wiring is missing (TS source isn't safely munged). New scaffolds ship both. 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`. |
39
43
  | `lotics install <package_id>` | Materialize a published package into the current workspace via `POST /v1/packages/{id}/installations` — an **app** package scaffolds, deploys, materializes and pins (reporting the app id and how to reach it); a **content** package delivers its docs and templates. Installs at the package's LATEST version; a version pin is the operator's concern and lives in `opctl`. **Bundled knowledge the install could not bind is NAMED, not counted** — an unbound doc leaves a working app whose agent reads nothing from it and answers from nowhere. Resolves and ANNOUNCES its workspace first (`lotics → <org> / <workspace>` on stderr) like every other data command. Admin-only, enforced server-side. Authoring the registry (`app publish/release/unpublish`, `package *`) stays operator-only. |
@@ -41,11 +45,9 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
41
45
  | `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. |
42
46
  | `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. |
43
47
  | `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 the app serves that the source names nowhere, 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 holding `run_app_query`/`run_app_workflow` with an EMPTY `query_aliases`/`workflow_aliases` (the tool is the capability, the alias list is the reach — empty 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). Adds no rule of its own — each finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **Exits 1 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. |
44
- | `lotics app workflow run <alias> '<json>'` | Execute a bound app workflow end-to-end via `appWorkflow`. `app_id` comes from the local manifest; the alias must be bound (`set_app_workflow`). Inputs ingest exactly like `lotics run` (inline JSON / `@file` / stdin — bulk inputs bypass `ARG_MAX`). Prints the full `{status,message,data,files,side_effects}` JSON to stdout + a one-line summary to stderr; exits non-zero on `status:"error"` (assertable). `--print-created` (alias `--report-effects`) renders the honest post-run harvest: created records grouped by table, a paste-ready `lotics run delete_records …` per table, then the **mandatory caveat** naming what cannot be auto-undone (external integrations + notifications) and that sub-workflows may have run. `--cleanup` (DEFAULT OFF, implies the report) additionally runs the deletes for harvested records ONLY — never files / external / notifications. Neither is a rollback — a rollback is structurally impossible here. |
45
48
  | `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. |
46
49
  | `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. |
47
50
  | `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. |
48
- | `lotics app agent run <app_id> <alias> ['<json>'\|@file\|stdin]` | Run a bound app agent end-to-end. A run needs no deployed UI bundle — just the app row + the bound agent declaration + member auth — so the **`app_id` is explicit** (not read from a local manifest). Inputs ingest exactly like `lotics run` (inline JSON / `@file` / stdin; empty = `{}`). Opens the run's SSE (`appAgentRunStream`), streams `text-delta` prose to **stderr** as live progress, then reports from the **settled run RECORD** (`listAgentRuns`, polled to a terminal status — the client stream can close a beat before the run settles, or drop while it runs on server-side): default prints the run's structured `output` (JSON) or final text to **stdout** + a status line to stderr; `--json` prints the full run summary to stdout. Selects THIS run by the `x-app-agent-run-id` header (ordering-independent). Exits 0 **only** when the settled status is `completed`; otherwise non-zero with the run's error surfaced. A settled run that never appears fails loudly (never a silent success). A fresh `session_id` is minted per run (self-contained); `--session <id>` continues an existing thread (prior runs become the agent's context). |
49
51
  | `lotics app workflow pull` | Rewrite every `src/workflows/<alias>.ts` from the server (faithful body per bound alias via `get_app_workflow`) **+ its `.lotics/workflows/<alias>.globals.d.ts`** (via `getAppWorkflowDts`, so the body is locally typecheckable via `lotics app workflow check`) without a full `app pull` (no source archive, no npm install). A legacy alias with no rendered source warns and is skipped; a dts-fetch failure is non-fatal (body still written with the fallback wrapper, typecheck degraded). Each alias's `description` is folded back into `package.json#lotics.workflows.<alias>` from the same read — the alias binding the manifest is otherwise stamped from carries `inputs`/`outputs` but not the description, which lives on the workflow ROW, so without this a pull would erase an authored one. The server's GENERATED default is skipped, so an app that never described its workflows gains no manifest noise. Also idempotently patches the main `tsconfig.json` `exclude` to cover `src/workflows` + `.lotics/workflows` so a pre-existing app's `npm run typecheck` never loads the bodies or the colliding per-alias globals. |
50
52
  | `lotics app workflow check [alias]` | Check the editable workflow bodies locally, no auth / no network, in the **server's own order** — parse, then type-check. **Parse** runs `parseWorkflowJs` from `@lotics/shared` (the SAME module `verifyWorkflow` calls, never a second implementation) over the stripped body `set` would upload, with `toolNames: undefined` (the CLI ships no tool registry, so tool-name resolution stays a server check while every shape/scope rule runs here). A body the subset rejects reports **that error alone** and skips the compiler — it never reaches the server's compiler either, so tsc's opinion of it is noise. **Type-check** then builds an **isolated** `ts.Program` per alias from exactly that alias's `{body, globals}` pair — mirroring the server, which verifies one body at a time — so the per-alias ambient `trigger` never collides and `trigger.app_workflow.inputs` is checked against the right alias. All aliases run in ONE node process (N programs, not N `tsc` spawns), with the SAME compile options the server uses at set-time verify (lib `es2022` with no DOM, target ES2022, strict, NodeNext, `types:[]`, skipLibCheck) and the app's OWN `typescript` (resolved from its `node_modules`, never bundled into the CLI). What the compiler sees is the **checked source**, not the file: `rewriteAccumulatorAppends` from `@lotics/shared` — the SAME transform the server applies before its set-time compile — is applied in memory, so a pulled body's canonical `out = concat(out, [item])` accumulator checks green here exactly as it saves there, and the body on disk is never rewritten. Reports `<file>:<line>:<col> - <TS####\|subset>` at the **physical** line in `src/workflows/<alias>.ts`, so an editor jump lands on the offending code (these are deliberately NOT `set`'s body-relative numbers — `set` prints no file path, so there is no format to agree with); exits non-zero if any alias fails. Green is honest but not total: `set` additionally resolves names, lints and structurally validates against the live workspace — passes that need its tables and tool schemas, so they cannot run offline, and the success line says so. A bound alias with no body file yet warns + skips; a body with no globals errors (naming `lotics app codegen`, which refreshes types WITHOUT touching the body — a pull would overwrite it). **It also keeps the types honest.** Each alias's `.lotics/workflows/<alias>.globals.d.ts` carries a `// lotics:declaration <hash>` stamp of the manifest declaration it was rendered from; `check` compares it to `package.json#lotics.workflows.<alias>` and, when they differ, re-renders that alias's dts from the LOCAL declaration before compiling. Without it the verdict was confidently wrong in the exact case an author needs it — declare an input, run `check`, and get `TS2339: Property 'x' does not exist` pointing at your body for a schema the types have never been told about. The server renders a dts from a SUPPLIED declaration, so this works before the manifest has ever been deployed, which is when it matters (the order is edit → check → set). This is the ONE thing `check` uses the API for: it is skipped entirely when the stamps match (the common case, so `check` stays instant and offline), and with no credentials or a failed fetch it WARNS and checks against the older types rather than blocking. A file written before the stamp existed reads as unknown, never as matching, so a pre-existing checkout heals on its first run. |
51
53
  | `lotics app subdomain <new-subdomain>` | Rename the app's public `<slug>.lotics.app` address via `PUT /v1/apps/{id}/subdomain`. app_id comes from the local `package.json` manifest; the chosen slug must be a valid DNS label and free; the old address stops resolving. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.154.0",
3
+ "version": "0.156.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {