@lotics/cli 0.116.0 → 0.127.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.
@@ -150,6 +150,14 @@ export declare class LoticsClient {
150
150
  readonly baseUrl: string;
151
151
  constructor(options: LoticsClientOptions);
152
152
  private throwResponseError;
153
+ /**
154
+ * The backend's `log()` middleware registers `user-agent` and
155
+ * `x-posthog-session-id` onto the per-request Logger, so they ride EVERY log
156
+ * line that request emits — the validation 400, the tool error, the timing.
157
+ * Sending them is therefore the whole of the correlation work: it turns an
158
+ * anonymous API-key request into "`app workflow set`, from cli 0.117.0, the
159
+ * fourth command of this session".
160
+ */
153
161
  private buildHeaders;
154
162
  private request;
155
163
  whoami(): Promise<{
@@ -236,6 +244,14 @@ export declare class LoticsClient {
236
244
  model_tier?: string;
237
245
  inputs?: Record<string, unknown>;
238
246
  outputs?: Record<string, unknown>;
247
+ /**
248
+ * The app's own queries/workflows this agent may call through
249
+ * `run_app_query` / `run_app_workflow`. These are REFERENCES that never
250
+ * appear in the client bundle, so anything asking "is this alias still
251
+ * used" must read them or it will answer no for an agent-driven app.
252
+ */
253
+ query_aliases?: string[];
254
+ workflow_aliases?: string[];
239
255
  }> | null;
240
256
  /**
241
257
  * Installation-level customization config values (the `useConfig()`
@@ -855,6 +871,10 @@ export declare class LoticsClient {
855
871
  outputs?: Record<string, unknown>;
856
872
  name?: string;
857
873
  description?: string;
874
+ /** The `body_sha` this push was built on. Makes the write conditional: the
875
+ * server refuses it when the live body has moved since, rather than
876
+ * letting a stale copy overwrite an edit its author never saw. */
877
+ expected_body_sha?: string;
858
878
  }): Promise<ToolExecuteResult>;
859
879
  /**
860
880
  * Bind (create or replace) an app query by alias via the `set_app_query` tool
@@ -868,7 +888,9 @@ export declare class LoticsClient {
868
888
  ast: unknown;
869
889
  params?: Record<string, unknown>;
870
890
  description?: string;
871
- }): Promise<ToolExecuteResult>;
891
+ },
892
+ /** The fingerprint this edit was based on — makes the write conditional. */
893
+ expected_sha?: string): Promise<ToolExecuteResult>;
872
894
  /**
873
895
  * Bind (create or replace) an app agent by alias via the `set_app_agent` tool
874
896
  * — the deploy-free authoring path for `apps.agents`, parallel to
@@ -1036,6 +1058,7 @@ export declare class LoticsClient {
1036
1058
  * (empty array when none declared).
1037
1059
  */
1038
1060
  workflow_aliases?: string[];
1061
+ agent_aliases?: string[];
1039
1062
  query_aliases?: string[];
1040
1063
  }): Promise<{
1041
1064
  version_id: string;
@@ -1,6 +1,7 @@
1
1
  import { transportErrorMessage } from "@lotics/shared/transport_error";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { getInvocation } from "./invocation.js";
4
5
  function findAvailableFilename(dir, filename, reserved) {
5
6
  // `reserved` tracks absolute paths claimed by in-flight downloads in the same
6
7
  // batch — required for parallel callers because the file may not be on disk
@@ -75,9 +76,21 @@ export class LoticsClient {
75
76
  }
76
77
  throw new Error(`${response.status}: ${message}`);
77
78
  }
79
+ /**
80
+ * The backend's `log()` middleware registers `user-agent` and
81
+ * `x-posthog-session-id` onto the per-request Logger, so they ride EVERY log
82
+ * line that request emits — the validation 400, the tool error, the timing.
83
+ * Sending them is therefore the whole of the correlation work: it turns an
84
+ * anonymous API-key request into "`app workflow set`, from cli 0.117.0, the
85
+ * fourth command of this session".
86
+ */
78
87
  buildHeaders() {
88
+ const invocation = getInvocation();
79
89
  const headers = {
80
90
  "Authorization": `Bearer ${this.apiKey}`,
91
+ // Unversioned when the SDK is used as a library — there is no CLI process
92
+ // whose version to name, and a wrong one is worse than none.
93
+ "user-agent": invocation?.userAgent ?? "lotics-cli",
81
94
  };
82
95
  if (this.workspaceId) {
83
96
  headers["x-workspace-id"] = this.workspaceId;
@@ -85,6 +98,12 @@ export class LoticsClient {
85
98
  if (this.viewAsMemberId) {
86
99
  headers["x-view-as-member-id"] = this.viewAsMemberId;
87
100
  }
101
+ if (invocation) {
102
+ headers["x-lotics-cli-command"] = invocation.command;
103
+ if (invocation.session !== null) {
104
+ headers["x-posthog-session-id"] = invocation.session;
105
+ }
106
+ }
88
107
  return headers;
89
108
  }
90
109
  async request(method, path, body) {
@@ -547,6 +566,7 @@ export class LoticsClient {
547
566
  ...(body.outputs ? { outputs: body.outputs } : {}),
548
567
  ...(body.name ? { name: body.name } : {}),
549
568
  ...(body.description ? { description: body.description } : {}),
569
+ ...(body.expected_body_sha ? { expected_body_sha: body.expected_body_sha } : {}),
550
570
  });
551
571
  }
552
572
  /**
@@ -557,8 +577,15 @@ export class LoticsClient {
557
577
  * deploy validates the manifest. Note: `apps.queries` is manifest-authoritative,
558
578
  * so the next `lotics app deploy` overwrites this from the manifest.
559
579
  */
560
- async setAppQuery(app_id, alias, declaration) {
561
- return this.execute("set_app_query", { app_id, alias, declaration });
580
+ async setAppQuery(app_id, alias, declaration,
581
+ /** The fingerprint this edit was based on — makes the write conditional. */
582
+ expected_sha) {
583
+ return this.execute("set_app_query", {
584
+ app_id,
585
+ alias,
586
+ declaration,
587
+ ...(expected_sha ? { expected_sha } : {}),
588
+ });
562
589
  }
563
590
  /**
564
591
  * Bind (create or replace) an app agent by alias via the `set_app_agent` tool
@@ -723,6 +750,7 @@ export class LoticsClient {
723
750
  // server records what the served version calls — the remove_app_workflow
724
751
  // guard reads this back. These are the manifest KEYS only, never bindings.
725
752
  formData.append("workflow_aliases", JSON.stringify(args.workflow_aliases ?? []));
753
+ formData.append("agent_aliases", JSON.stringify(args.agent_aliases ?? []));
726
754
  formData.append("query_aliases", JSON.stringify(args.query_aliases ?? []));
727
755
  const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(args.app_id)}/versions`;
728
756
  const response = await fetch(url, {
@@ -0,0 +1,51 @@
1
+ /** Enabled by an explicit `LOTICS_TELEMETRY=1`. Anything else, including unset, is off. */
2
+ export declare function telemetryEnabled(env?: NodeJS.ProcessEnv): boolean;
3
+ /**
4
+ * The verb path of an invocation — `app.workflow.set`, `run.query_records` —
5
+ * from the raw argv positionals.
6
+ *
7
+ * Positionals stop at the first token that is a flag, a JSON blob, an `@file`,
8
+ * or an identifier: the point of the header is to name the COMMAND, and a table
9
+ * id or a record payload past that point is customer data that has no business
10
+ * in a request header. `run` keeps one extra token because the tool name IS the
11
+ * verb there — `run` alone would collapse ~90 distinct operations into one label.
12
+ */
13
+ export declare function commandPath(argv: readonly string[]): string;
14
+ /** `lotics-cli/0.117.0 node/v22.1.0 linux` — enough to correlate a failure with a stale CLI. */
15
+ export declare function userAgent(version: string): string;
16
+ /**
17
+ * The command + session of the running CLI process, set once by `cli.ts` before
18
+ * dispatch and read by `LoticsClient.buildHeaders`.
19
+ *
20
+ * Process-scoped ambient state, which is a cost — but the alternative is
21
+ * threading two strings through the ~20 places a client is constructed, and the
22
+ * facts are genuinely process-wide (there is one command per invocation). It is
23
+ * write-once at startup and read-only after, so nothing downstream has to track
24
+ * when it changes.
25
+ *
26
+ * Unset is the LIBRARY case: `@lotics/cli` also exports `LoticsClient`, where
27
+ * `process.argv` belongs to someone else's program and would name a command that
28
+ * was never run. Absent beats wrong, so the header is simply omitted.
29
+ */
30
+ type Invocation = {
31
+ command: string;
32
+ session: string | null;
33
+ userAgent: string;
34
+ };
35
+ /**
36
+ * `version` is passed in rather than imported: `version.ts` resolves
37
+ * `package.json` relative to its own module URL, which only holds from `dist/`,
38
+ * so importing it into `client.ts` would make the library entry unloadable from
39
+ * source. The bin already reads it correctly; the client just relays it.
40
+ */
41
+ export declare function setInvocation(command: string, session: string | null, version: string): void;
42
+ export declare function getInvocation(): Invocation | null;
43
+ /** Back to the library state (no CLI process). Exists for tests. */
44
+ export declare function resetInvocation(): void;
45
+ export declare function sessionStorePath(): string;
46
+ /**
47
+ * The current session id, or null when telemetry is off. `now` is injected so the
48
+ * idle-gap boundary is testable without a clock.
49
+ */
50
+ export declare function sessionId(now?: number, env?: NodeJS.ProcessEnv): string | null;
51
+ export {};
@@ -0,0 +1,142 @@
1
+ /**
2
+ * What this process was asked to do, and which run of a sitting it belongs to —
3
+ * the two facts that make a server log line attributable to a CLI invocation.
4
+ *
5
+ * Today a CLI request is indistinguishable from any other API-key request: the
6
+ * backend's `log()` middleware registers `user_agent` and `$session_id` onto
7
+ * every log line of a request, and the CLI sent neither. So a 400 in PostHog
8
+ * names an endpoint and nothing about the command that produced it, and nothing
9
+ * links it to the twelve invocations that preceded it — which is the whole
10
+ * question when an agent is stuck in a retry loop.
11
+ *
12
+ * Split by sensitivity, deliberately:
13
+ * - the VERSION and the COMMAND describe the request that is already being
14
+ * made (the endpoint mostly implies the verb anyway) and always ride;
15
+ * - the SESSION id is a cross-request correlator, so it rides only under
16
+ * `LOTICS_TELEMETRY=1`. Opt-out would be the industry default; this package
17
+ * publishes to npm and runs on customers' machines, so it is opt-in.
18
+ */
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ /** Enabled by an explicit `LOTICS_TELEMETRY=1`. Anything else, including unset, is off. */
23
+ export function telemetryEnabled(env = process.env) {
24
+ return env.LOTICS_TELEMETRY === "1";
25
+ }
26
+ /**
27
+ * The verb path of an invocation — `app.workflow.set`, `run.query_records` —
28
+ * from the raw argv positionals.
29
+ *
30
+ * Positionals stop at the first token that is a flag, a JSON blob, an `@file`,
31
+ * or an identifier: the point of the header is to name the COMMAND, and a table
32
+ * id or a record payload past that point is customer data that has no business
33
+ * in a request header. `run` keeps one extra token because the tool name IS the
34
+ * verb there — `run` alone would collapse ~90 distinct operations into one label.
35
+ */
36
+ export function commandPath(argv) {
37
+ const parts = [];
38
+ for (const raw of argv) {
39
+ if (parts.length >= 3)
40
+ break;
41
+ if (raw.startsWith("-") || raw.startsWith("@") || raw.startsWith("{"))
42
+ break;
43
+ if (raw.includes("/") || raw.includes("."))
44
+ break;
45
+ if (!/^[a-z][a-z0-9_-]*$/i.test(raw))
46
+ break;
47
+ if (isResourceId(raw))
48
+ break;
49
+ parts.push(raw);
50
+ }
51
+ return parts.length > 0 ? parts.join(".") : "unknown";
52
+ }
53
+ /**
54
+ * A Lotics resource id (`app_Gw6rs95ZYKQs`, `tbl_1KxO3W08g75o`) as opposed to a
55
+ * snake_case tool name (`query_records`, `grep_knowledge`).
56
+ *
57
+ * Both are `<word>_<word>`, so prefix-and-underscore alone is not the tell — it
58
+ * rejected half the tool registry. The discriminator is the SUFFIX: an id's is a
59
+ * long base62 blob, which always carries an uppercase letter or a digit; a tool
60
+ * name's second word is an English word in lowercase. Requiring both (a short
61
+ * prefix with a long mixed-case suffix, and no second underscore) leaves
62
+ * `set_app_workflow` and `aggregate_records` on the verb side where they belong.
63
+ */
64
+ function isResourceId(token) {
65
+ const match = /^[a-z]{2,5}_([A-Za-z0-9]{8,})$/.exec(token);
66
+ return match !== null && /[A-Z0-9]/.test(match[1]);
67
+ }
68
+ /** `lotics-cli/0.117.0 node/v22.1.0 linux` — enough to correlate a failure with a stale CLI. */
69
+ export function userAgent(version) {
70
+ return `lotics-cli/${version} node/${process.version} ${os.platform()}`;
71
+ }
72
+ let invocation = null;
73
+ /**
74
+ * `version` is passed in rather than imported: `version.ts` resolves
75
+ * `package.json` relative to its own module URL, which only holds from `dist/`,
76
+ * so importing it into `client.ts` would make the library entry unloadable from
77
+ * source. The bin already reads it correctly; the client just relays it.
78
+ */
79
+ export function setInvocation(command, session, version) {
80
+ invocation = { command, session, userAgent: userAgent(version) };
81
+ }
82
+ export function getInvocation() {
83
+ return invocation;
84
+ }
85
+ /** Back to the library state (no CLI process). Exists for tests. */
86
+ export function resetInvocation() {
87
+ invocation = null;
88
+ }
89
+ /**
90
+ * A sitting of the CLI, as one id shared by every invocation in it.
91
+ *
92
+ * Under an agent harness that already has a session concept, adopt it — the ids
93
+ * then line up with the harness's own transcript, which is the difference between
94
+ * "these 40 commands are related" and "these 40 commands ARE that conversation".
95
+ * Otherwise sessionize the way web analytics does: reuse the stored id while
96
+ * invocations keep arriving, mint a new one after an idle gap.
97
+ */
98
+ const IDLE_GAP_MS = 30 * 60 * 1000;
99
+ export function sessionStorePath() {
100
+ return path.join(os.homedir(), ".lotics", "session.json");
101
+ }
102
+ function readSession(file) {
103
+ try {
104
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
105
+ if (typeof parsed !== "object" || parsed === null)
106
+ return null;
107
+ const { id, last_seen } = parsed;
108
+ if (typeof id !== "string" || typeof last_seen !== "number")
109
+ return null;
110
+ return { id, last_seen };
111
+ }
112
+ catch {
113
+ // Absent, unreadable, or corrupt — all mean "no session to continue", and a
114
+ // telemetry id is never worth failing a command over.
115
+ return null;
116
+ }
117
+ }
118
+ /**
119
+ * The current session id, or null when telemetry is off. `now` is injected so the
120
+ * idle-gap boundary is testable without a clock.
121
+ */
122
+ export function sessionId(now = Date.now(), env = process.env) {
123
+ if (!telemetryEnabled(env))
124
+ return null;
125
+ const harnessSession = env.CLAUDE_CODE_SESSION_ID;
126
+ if (harnessSession !== undefined && harnessSession !== "")
127
+ return harnessSession;
128
+ const file = sessionStorePath();
129
+ const stored = readSession(file);
130
+ const id = stored !== null && now - stored.last_seen < IDLE_GAP_MS
131
+ ? stored.id
132
+ : `cli_${now.toString(36)}${Math.random().toString(36).slice(2, 10)}`;
133
+ try {
134
+ fs.mkdirSync(path.dirname(file), { recursive: true });
135
+ fs.writeFileSync(file, JSON.stringify({ id, last_seen: now }), { mode: 0o600 });
136
+ }
137
+ catch {
138
+ // A read-only home still gets a usable id for this process; only the
139
+ // continuity across invocations is lost.
140
+ }
141
+ return id;
142
+ }
@@ -29,11 +29,11 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
29
29
  | `lotics knowledge update <id> [--from <file.md> \| --content <str>] [--name <n>] [--description <d>]` | Call `update_knowledge` with **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). At least one field required; --from and --content are mutually exclusive. |
30
30
  | `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. |
31
31
  | `lotics app create <name> [path]` | Scaffold a Vite+React+TS custom-code app project; POST /v1/apps; npm install; vite build; upload as v1 |
32
- | `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; this avoids the stray nested `./<name>/` subdir a pull-from-inside-the-app used to drop. — `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 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 |
33
- | `lotics app deploy -m <message>` | **`-m` is REQUIRED** (CLI errors without a non-empty message) — each deploy is a version row read back by `lotics app versions`, so a blank message loses the audit trail. npm run build; tar source + dist; POST /v1/apps/{id}/versions multipart. Carries code + capabilities only — **neither queries nor workflow/agent bindings are a deploy concern** (`set_app_workflow` / `remove_app_workflow` own `apps.workflows`; the manifest's `workflows` map is a pulled reflection, read by `useWorkflow` codegen and by `app workflow set`, never written by a deploy). Deploy DOES send the manifest's `lotics.workflows` alias KEYS (not the bindings) as `workflow_aliases`, recorded on the version row so `remove_app_workflow` can refuse to unbind an alias the served version still declares. It also reports any `lotics.queries` alias whose declaration DIFFERS from the app's, naming both recoveries (`app query set --all` to push yours, `app pull` to adopt the app's) — a deploy no longer writes them, so the two are allowed to drift. After a successful deploy it **warns loudly about any alias the source CALLS that is NOT bound on the server** (a `getApp` diff via `warnIfUnboundAliases`) — since deploy never binds them, that would otherwise throw only at the app's first `useWorkflow` / `useAgentRun` call; the warning points to `lotics app workflow set` / `set_app_agent`. Advisory only (never fails the deploy). |
32
+ | `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; this avoids the stray nested `./<name>/` subdir a pull-from-inside-the-app used to drop. **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. An in-place refresh used to replace uncommitted work silently, since `tar -xzf` overwrites unconditionally and the command still exits 0. The same rule covers the two kinds the archive does NOT carry — `src/workflows/<alias>.ts` and `src/agents/<alias>.md`, both rewritten from the live row on every pull — so an unpushed body or prompt survives too. 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. — `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 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 |
33
+ | `lotics app deploy -m <message>` | `-m` is OPTIONAL — omitted, the deploy derives the version message from what it actually pushed. Requiring it was the most common real failure in CLI telemetry, and a hard stop yields a retry plus filler rather than an audit trail; 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. 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`. Deploy also sends the manifest's `lotics.workflows` alias KEYS as `workflow_aliases`, recorded on the version row so `remove_app_workflow` can refuse to unbind an alias the served version still declares. After a successful deploy it warns about any alias the source CALLS that is NOT bound, and **unbinds the inverse** — bindings the app still serves that this bundle names nowhere. No flag: an orphan is a live, callable read path under the deployer's authority, and a deploy that adds bindings automatically but requires a decision to remove one just accumulates them. Unbinding 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. It 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; that is the only case where the reference set is incomplete, because a binding is reachable from the bundle and from an agent's `query_aliases`/`workflow_aliases` and from nothing else — an app workflow carries no `on({...})` trigger, so no table event or schedule reaches one. A binding that will not unbind is reported and does NOT fail the release: the version is live and correct, and the leftover is the state every deploy left behind before this existed. |
34
34
  | `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. The deploy pipeline already persisted all of this in `app_versions`; this is the read surface. Title → stderr, table → stdout (pipeable). |
35
35
  | `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 (`ensureAppVitestSetup`, folded into the same write boundary): 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, mirroring `ensureAppTsconfig`'s JSONC-tsconfig warn). 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 (that directory is read as the app's alias inventory, so a companion for a binding nobody can reach misreports what the app has). 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. Refreshing here makes the divergence self-healing on a command already in the loop and keeps the remedy off `app pull` (which rewrites `src/workflows/*.ts` and would eat uncommitted body edits). The write is surgical and order-preserving (`orderedLike`), 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`. |
36
- | `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping: the manifest's agent schemas against the live app row, aliases the source calls that nothing bound (queries, workflows AND agents), capability-gated SDK calls the manifest doesn't declare, `lotics.queries` drift, a missing icon/theme, and a notice for any alias the source computes at runtime (invisible to every check here and to the deploy'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 only on what `deploy` REFUSES** (an agent schema that disagrees with the live app), so CI can gate on it while advisories stay advisory. It also names any `src/agents/<alias>.md` that differs from the live agent's instructions — a WARNING, not a gate, because `deploy` never pushes prose and shipping unrelated UI while a prompt is mid-edit is normal; a stale prompt does not make the bundle lie about its own types the way a stale schema does. The point is the question being ASKABLE: these checks used to cost a build, a tar, an upload and a version row in the audit trail, which is expensive enough that the honest move was to skip them and find out in production. |
36
+ | `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping: 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, and a notice for any alias the source computes at runtime (invisible to every check here and to the deploy'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. The point is the question being ASKABLE: these checks used to cost a build, a tar, an upload and a version row in the audit trail, which is expensive enough that the honest move was to skip them and find out in production. |
37
37
  | `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. |
38
38
  | `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. |
39
39
  | `lotics app agent set <alias>` | Push the edited `src/agents/<alias>.md` instructions back through `set_app_agent` — the agent mirror of `app workflow set`, and the deploy-free authoring path for an agent's PROSE. It sends the instructions and nothing else: the server merges against the stored declaration, so every typed field keeps exactly what is bound. This is deliberate and it is the opposite of what the symmetry with `app workflow set` suggests — **the manifest is a snapshot from the last `app pull`, so replaying its typed half would silently revert whatever was bound since** (the chat authoring agent adding `knowledge_doc_ids`, another operator granting `query_aliases`), and the CLI would print success while the agent quietly lost its knowledge and its read surface. To change a typed field, call `set_app_agent` with just that field (`lotics run set_app_agent '{"app_id":…,"alias":…,"outputs":{…}}'` — it merges), then `app pull` to bring the manifest back in step. Editing `package.json#lotics.agents` by hand pushes nothing, and since that block is what types `useAgentRun`, `codegen` warns and `deploy` REFUSES while it disagrees with the live app. Clear error + non-zero exit on a missing file, an alias absent from the manifest, or a file that is empty once the header is stripped (refusing to push an empty prompt). `app pull` writes the file; edit, then `set`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.116.0",
3
+ "version": "0.127.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {