@lotics/cli 0.185.0 → 0.186.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +174 -10
- package/dist/src/client.d.ts +1 -0
- package/dist/src/client.js +1032 -891
- package/docs/cli_reference.md +1 -1
- package/package.json +1 -1
- package/dist/src/invocation.js +0 -142
package/docs/cli_reference.md
CHANGED
|
@@ -27,7 +27,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
27
27
|
| — | **The exit code reports the WORK, not just the call — for the two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`) exit non-zero and print `<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. Any OTHER tool's `status` — `press_button`'s included — is data, and exits 0. |
|
|
28
28
|
| `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. |
|
|
29
29
|
| `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. |
|
|
30
|
-
| `lotics upload <file\|dir...>` · `--stdin` · `--base64` · `--url <url>` | Upload files/directories
|
|
30
|
+
| `lotics upload <file\|dir...>` · `--stdin` · `--base64` · `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files` in one request, and several such files go in the same one; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose — one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. 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. |
|
|
31
31
|
| `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. |
|
|
32
32
|
| `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
33
|
| `lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> \| --content <str>)` | Read the body client-side (a file XOR an inline string — exactly one required), then call `create_knowledge` with `{ name, description, content, tags? }` (description defaults to `""`). `--tags` files the doc as it is made, which is the only moment a corpus reliably gets labelled. Prints the new id to stdout. Large files ride the POST body fine. |
|
package/package.json
CHANGED
package/dist/src/invocation.js
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
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
|
-
}
|