@lotics/cli 0.150.2 → 0.151.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 +160 -19
- package/dist/src/client.d.ts +11 -0
- package/dist/src/client.js +32 -6
- package/docs/cli_reference.md +1 -1
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -45132,14 +45132,27 @@ var LoticsClient = class {
|
|
|
45132
45132
|
);
|
|
45133
45133
|
}
|
|
45134
45134
|
async uploadFiles(filePaths, options) {
|
|
45135
|
-
const
|
|
45135
|
+
const items = [];
|
|
45136
45136
|
for (let i2 = 0; i2 < filePaths.length; i2++) {
|
|
45137
45137
|
const absolutePath = path2.resolve(filePaths[i2]);
|
|
45138
|
-
|
|
45139
|
-
|
|
45140
|
-
|
|
45141
|
-
|
|
45142
|
-
|
|
45138
|
+
items.push({
|
|
45139
|
+
bytes: await fs2.promises.readFile(absolutePath),
|
|
45140
|
+
filename: options?.filenames?.[i2] ?? path2.basename(absolutePath)
|
|
45141
|
+
});
|
|
45142
|
+
}
|
|
45143
|
+
return this.uploadFileBytes(items);
|
|
45144
|
+
}
|
|
45145
|
+
/**
|
|
45146
|
+
* Store files from bytes the caller already holds — the path for a caller that
|
|
45147
|
+
* never had them on disk (an email attachment decoded in memory, a generated
|
|
45148
|
+
* document, a fetched URL). `uploadFiles` is this with a read in front, so
|
|
45149
|
+
* both routes hit one endpoint and one mime-derivation rule.
|
|
45150
|
+
*/
|
|
45151
|
+
async uploadFileBytes(items) {
|
|
45152
|
+
const formData = new FormData();
|
|
45153
|
+
for (const item of items) {
|
|
45154
|
+
const mimeType = item.mimeType ?? getMimeType(item.filename);
|
|
45155
|
+
formData.append("file", new Blob([item.bytes], { type: mimeType }), item.filename);
|
|
45143
45156
|
}
|
|
45144
45157
|
const url2 = `${this.baseUrl}/v1/files`;
|
|
45145
45158
|
const response = await fetch(url2, {
|
|
@@ -45152,6 +45165,92 @@ var LoticsClient = class {
|
|
|
45152
45165
|
}
|
|
45153
45166
|
};
|
|
45154
45167
|
|
|
45168
|
+
// src/upload_source.ts
|
|
45169
|
+
var MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
45170
|
+
var URL_FETCH_TIMEOUT_MS = 3e4;
|
|
45171
|
+
async function readStdinBytes(limit) {
|
|
45172
|
+
const chunks = [];
|
|
45173
|
+
let total = 0;
|
|
45174
|
+
for await (const chunk of process.stdin) {
|
|
45175
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
45176
|
+
total += buf.byteLength;
|
|
45177
|
+
if (total > limit) return { tooLarge: true };
|
|
45178
|
+
chunks.push(buf);
|
|
45179
|
+
}
|
|
45180
|
+
return Buffer.concat(chunks);
|
|
45181
|
+
}
|
|
45182
|
+
function decodeBase64Strict(input) {
|
|
45183
|
+
const text = Buffer.from(input).toString("utf8").trim();
|
|
45184
|
+
const canonical = text.replace(/\s+/g, "").replace(/=+$/, "");
|
|
45185
|
+
if (canonical.length > 0 && !/^[A-Za-z0-9+/]+$/.test(canonical)) return null;
|
|
45186
|
+
const decoded = Buffer.from(text, "base64");
|
|
45187
|
+
if (decoded.toString("base64").replace(/=+$/, "") !== canonical) return null;
|
|
45188
|
+
return decoded;
|
|
45189
|
+
}
|
|
45190
|
+
function filenameFromResponse(headers, url2) {
|
|
45191
|
+
const disposition = headers.get("content-disposition");
|
|
45192
|
+
const encoded = disposition?.match(/filename\*=\s*(?:UTF-8'')?"?([^";]+)"?/i);
|
|
45193
|
+
const plain = disposition?.match(/filename=\s*"?([^";]+)"?/i);
|
|
45194
|
+
const raw = encoded?.[1] ?? plain?.[1];
|
|
45195
|
+
if (raw) return decodeURIComponent(raw.trim());
|
|
45196
|
+
const last3 = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
45197
|
+
return last3 && last3.includes(".") ? decodeURIComponent(last3) : void 0;
|
|
45198
|
+
}
|
|
45199
|
+
async function resolveUploadSource(sourceFlag, flags) {
|
|
45200
|
+
let bytes;
|
|
45201
|
+
let filename = flags.as;
|
|
45202
|
+
if (flags.url !== void 0) {
|
|
45203
|
+
const controller = new AbortController();
|
|
45204
|
+
const timer2 = setTimeout(() => controller.abort(), URL_FETCH_TIMEOUT_MS);
|
|
45205
|
+
let response;
|
|
45206
|
+
try {
|
|
45207
|
+
response = await fetch(flags.url, { signal: controller.signal });
|
|
45208
|
+
} catch (e) {
|
|
45209
|
+
const reason = controller.signal.aborted ? `no response within ${URL_FETCH_TIMEOUT_MS}ms` : e instanceof Error ? e.message : String(e);
|
|
45210
|
+
return { error: `Fetch failed for ${flags.url}: ${reason}` };
|
|
45211
|
+
} finally {
|
|
45212
|
+
clearTimeout(timer2);
|
|
45213
|
+
}
|
|
45214
|
+
if (!response.ok) {
|
|
45215
|
+
return { error: `Fetch failed: ${response.status} ${response.statusText} for ${flags.url}` };
|
|
45216
|
+
}
|
|
45217
|
+
const declared = Number(response.headers.get("content-length"));
|
|
45218
|
+
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
|
|
45219
|
+
return { error: `That URL returns ${declared} bytes; the upload limit is ${MAX_UPLOAD_BYTES}.` };
|
|
45220
|
+
}
|
|
45221
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
45222
|
+
filename ??= filenameFromResponse(response.headers, flags.url);
|
|
45223
|
+
} else {
|
|
45224
|
+
const read = await readStdinBytes(MAX_UPLOAD_BYTES);
|
|
45225
|
+
if ("tooLarge" in read) {
|
|
45226
|
+
return { error: `stdin exceeded the ${MAX_UPLOAD_BYTES}-byte upload limit.` };
|
|
45227
|
+
}
|
|
45228
|
+
if (read.byteLength === 0) {
|
|
45229
|
+
return { error: `${sourceFlag} was given but stdin was empty \u2014 pipe the bytes in.` };
|
|
45230
|
+
}
|
|
45231
|
+
if (flags.base64) {
|
|
45232
|
+
const decoded = decodeBase64Strict(read);
|
|
45233
|
+
if (decoded === null) {
|
|
45234
|
+
return {
|
|
45235
|
+
error: "--base64 input is not valid base64: it contains characters outside the standard alphabet (base64url is not accepted) or is truncated."
|
|
45236
|
+
};
|
|
45237
|
+
}
|
|
45238
|
+
bytes = decoded;
|
|
45239
|
+
} else {
|
|
45240
|
+
bytes = read;
|
|
45241
|
+
}
|
|
45242
|
+
}
|
|
45243
|
+
if (bytes.byteLength > MAX_UPLOAD_BYTES) {
|
|
45244
|
+
return { error: `That is ${bytes.byteLength} bytes; the upload limit is ${MAX_UPLOAD_BYTES}.` };
|
|
45245
|
+
}
|
|
45246
|
+
if (filename === void 0) {
|
|
45247
|
+
return {
|
|
45248
|
+
error: "--as <name> is required: no filename could be derived from the source, and the mime type comes from it."
|
|
45249
|
+
};
|
|
45250
|
+
}
|
|
45251
|
+
return { bytes, filename };
|
|
45252
|
+
}
|
|
45253
|
+
|
|
45155
45254
|
// src/telemetry.ts
|
|
45156
45255
|
import crypto from "node:crypto";
|
|
45157
45256
|
import fs3 from "node:fs";
|
|
@@ -45928,6 +46027,9 @@ var COMMANDS = [
|
|
|
45928
46027
|
aliases: ["upload", "download", "preview"],
|
|
45929
46028
|
help: [
|
|
45930
46029
|
" lotics file upload <file|dir...> Upload files (alias: lotics upload)",
|
|
46030
|
+
" lotics file upload --stdin --as <name>",
|
|
46031
|
+
" Upload bytes you already hold, piped on stdin",
|
|
46032
|
+
" (--base64 for base64; --url <url> to fetch first)",
|
|
45931
46033
|
" lotics file download <file_id> Download a file by ID (alias: lotics download)",
|
|
45932
46034
|
" lotics file download record <record_id> <field_key>",
|
|
45933
46035
|
" Download all files on a record file field",
|
|
@@ -74393,6 +74495,9 @@ function parseArgs(argv) {
|
|
|
74393
74495
|
const flags = {
|
|
74394
74496
|
json: false,
|
|
74395
74497
|
force: false,
|
|
74498
|
+
stdin: false,
|
|
74499
|
+
base64: false,
|
|
74500
|
+
url: void 0,
|
|
74396
74501
|
fromVersion: void 0,
|
|
74397
74502
|
timeout: void 0,
|
|
74398
74503
|
output: void 0,
|
|
@@ -74430,6 +74535,15 @@ function parseArgs(argv) {
|
|
|
74430
74535
|
case "--force":
|
|
74431
74536
|
flags.force = true;
|
|
74432
74537
|
break;
|
|
74538
|
+
case "--stdin":
|
|
74539
|
+
flags.stdin = true;
|
|
74540
|
+
break;
|
|
74541
|
+
case "--base64":
|
|
74542
|
+
flags.base64 = true;
|
|
74543
|
+
break;
|
|
74544
|
+
case "--url":
|
|
74545
|
+
flags.url = argv[++i2];
|
|
74546
|
+
break;
|
|
74433
74547
|
case "--from-version":
|
|
74434
74548
|
flags.fromVersion = argv[++i2];
|
|
74435
74549
|
break;
|
|
@@ -102791,7 +102905,10 @@ FLAGS
|
|
|
102791
102905
|
--json Full JSON output (default is human-readable text)
|
|
102792
102906
|
--timeout <ms> Timeout for tool execution (default: 60000)
|
|
102793
102907
|
-o <path> Output dir for downloads
|
|
102794
|
-
--as <name> Override upload filename
|
|
102908
|
+
--as <name> Override upload filename (required with --stdin/--base64)
|
|
102909
|
+
--stdin Upload raw bytes piped on stdin instead of a path
|
|
102910
|
+
--base64 Upload base64 piped on stdin (decoded strictly)
|
|
102911
|
+
--url <url> Fetch a URL and upload what it returns
|
|
102795
102912
|
--api-key <key> One-off API key (overrides saved config + env)
|
|
102796
102913
|
--workspace <id> One-off workspace override (alias: -w)
|
|
102797
102914
|
--view-as <id> Admin "View as": run every request as this member, so
|
|
@@ -103394,8 +103511,12 @@ async function main() {
|
|
|
103394
103511
|
console.error('Run "lotics tools" to see available tools.');
|
|
103395
103512
|
process.exit(1);
|
|
103396
103513
|
}
|
|
103397
|
-
if (command === "upload" && !subcommand) {
|
|
103398
|
-
console.error("Usage:
|
|
103514
|
+
if (command === "upload" && !subcommand && !flags.stdin && !flags.base64 && !flags.url) {
|
|
103515
|
+
console.error("Usage:");
|
|
103516
|
+
console.error(" lotics upload <file|dir...> [--as <name>]");
|
|
103517
|
+
console.error(" lotics upload --stdin --as <name> Raw bytes on stdin");
|
|
103518
|
+
console.error(" lotics upload --base64 --as <name> Base64 on stdin");
|
|
103519
|
+
console.error(" lotics upload --url <url> [--as <name>]");
|
|
103399
103520
|
console.error("Uploads files and returns their file_ids. Directories expand to their immediate files.");
|
|
103400
103521
|
process.exit(1);
|
|
103401
103522
|
}
|
|
@@ -103799,19 +103920,39 @@ ${JSON.stringify(info.input_schema, null, 2)}`);
|
|
|
103799
103920
|
return;
|
|
103800
103921
|
}
|
|
103801
103922
|
if (command === "upload") {
|
|
103802
|
-
const
|
|
103803
|
-
|
|
103804
|
-
|
|
103805
|
-
console.error("No files found in the specified paths.");
|
|
103923
|
+
const sources = [flags.stdin && "--stdin", flags.base64 && "--base64", flags.url && "--url"].filter(Boolean);
|
|
103924
|
+
if (sources.length > 1) {
|
|
103925
|
+
console.error(`Pick one source: ${sources.join(", ")} were all given.`);
|
|
103806
103926
|
process.exit(1);
|
|
103807
103927
|
}
|
|
103808
|
-
|
|
103809
|
-
|
|
103810
|
-
|
|
103928
|
+
let upload;
|
|
103929
|
+
if (sources.length === 1) {
|
|
103930
|
+
const positional = [subcommand, toolArgs, ...restArgs].filter((v) => v !== void 0);
|
|
103931
|
+
if (positional.length > 0) {
|
|
103932
|
+
console.error(`${sources[0]} reads the bytes itself \u2014 drop the path argument (${positional.join(", ")}).`);
|
|
103933
|
+
process.exit(1);
|
|
103934
|
+
}
|
|
103935
|
+
const resolved = await resolveUploadSource(String(sources[0]), flags);
|
|
103936
|
+
if ("error" in resolved) {
|
|
103937
|
+
console.error(resolved.error);
|
|
103938
|
+
process.exit(1);
|
|
103939
|
+
}
|
|
103940
|
+
upload = await client.uploadFileBytes([resolved]);
|
|
103941
|
+
} else {
|
|
103942
|
+
const rawPaths = [subcommand, ...toolArgs ? [toolArgs] : [], ...restArgs];
|
|
103943
|
+
const filePaths = resolveUploadPaths(rawPaths);
|
|
103944
|
+
if (filePaths.length === 0) {
|
|
103945
|
+
console.error("No files found in the specified paths.");
|
|
103946
|
+
process.exit(1);
|
|
103947
|
+
}
|
|
103948
|
+
if (flags.as && filePaths.length > 1) {
|
|
103949
|
+
console.error("Cannot use --as with multiple files.");
|
|
103950
|
+
process.exit(1);
|
|
103951
|
+
}
|
|
103952
|
+
upload = await client.uploadFiles(filePaths, {
|
|
103953
|
+
filenames: flags.as ? [flags.as] : void 0
|
|
103954
|
+
});
|
|
103811
103955
|
}
|
|
103812
|
-
const upload = await client.uploadFiles(filePaths, {
|
|
103813
|
-
filenames: flags.as ? [flags.as] : void 0
|
|
103814
|
-
});
|
|
103815
103956
|
if (upload.files.length === 0 && upload.errors.length > 0) {
|
|
103816
103957
|
console.error(`Upload failed: ${upload.errors.map((e) => `${e.filename}: ${e.error}`).join(", ")}`);
|
|
103817
103958
|
process.exit(1);
|
package/dist/src/client.d.ts
CHANGED
|
@@ -1112,4 +1112,15 @@ export declare class LoticsClient {
|
|
|
1112
1112
|
uploadFiles(filePaths: string[], options?: {
|
|
1113
1113
|
filenames?: string[];
|
|
1114
1114
|
}): Promise<FileUploadResult>;
|
|
1115
|
+
/**
|
|
1116
|
+
* Store files from bytes the caller already holds — the path for a caller that
|
|
1117
|
+
* never had them on disk (an email attachment decoded in memory, a generated
|
|
1118
|
+
* document, a fetched URL). `uploadFiles` is this with a read in front, so
|
|
1119
|
+
* both routes hit one endpoint and one mime-derivation rule.
|
|
1120
|
+
*/
|
|
1121
|
+
uploadFileBytes(items: {
|
|
1122
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
1123
|
+
filename: string;
|
|
1124
|
+
mimeType?: string;
|
|
1125
|
+
}[]): Promise<FileUploadResult>;
|
|
1115
1126
|
}
|
package/dist/src/client.js
CHANGED
|
@@ -846,14 +846,40 @@ export class LoticsClient {
|
|
|
846
846
|
}));
|
|
847
847
|
}
|
|
848
848
|
async uploadFiles(filePaths, options) {
|
|
849
|
-
|
|
849
|
+
// Sequential, not `Promise.all`: `lotics upload <dir>` expands a directory to
|
|
850
|
+
// every file in it, and reading them all at once opens one handle per file —
|
|
851
|
+
// EMFILE on a large directory. The upload itself is a single request either
|
|
852
|
+
// way, so concurrency here buys nothing.
|
|
853
|
+
const items = [];
|
|
850
854
|
for (let i = 0; i < filePaths.length; i++) {
|
|
851
855
|
const absolutePath = path.resolve(filePaths[i]);
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
856
|
+
items.push({
|
|
857
|
+
bytes: await fs.promises.readFile(absolutePath),
|
|
858
|
+
filename: options?.filenames?.[i] ?? path.basename(absolutePath),
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
return this.uploadFileBytes(items);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Store files from bytes the caller already holds — the path for a caller that
|
|
865
|
+
* never had them on disk (an email attachment decoded in memory, a generated
|
|
866
|
+
* document, a fetched URL). `uploadFiles` is this with a read in front, so
|
|
867
|
+
* both routes hit one endpoint and one mime-derivation rule.
|
|
868
|
+
*/
|
|
869
|
+
async uploadFileBytes(
|
|
870
|
+
// `Uint8Array<ArrayBuffer>`, not a bare `Uint8Array`: `Blob` takes a
|
|
871
|
+
// `BufferSource`, which excludes a `SharedArrayBuffer`-backed view. Every
|
|
872
|
+
// real source here (`readFile`, `Buffer.concat`, `response.arrayBuffer()`)
|
|
873
|
+
// is already ArrayBuffer-backed, so the narrower type states the
|
|
874
|
+
// requirement rather than casting it away at the call site.
|
|
875
|
+
items) {
|
|
876
|
+
const formData = new FormData();
|
|
877
|
+
for (const item of items) {
|
|
878
|
+
const mimeType = item.mimeType ?? getMimeType(item.filename);
|
|
879
|
+
// `Blob` copies a BufferSource by the VIEW's offset+length, so passing a
|
|
880
|
+
// pooled `Buffer` from `readFile` straight in is correct — no defensive
|
|
881
|
+
// re-copy, which would double peak memory on every upload.
|
|
882
|
+
formData.append("file", new Blob([item.bytes], { type: mimeType }), item.filename);
|
|
857
883
|
}
|
|
858
884
|
const url = `${this.baseUrl}/v1/files`;
|
|
859
885
|
const response = await fetch(url, {
|
package/docs/cli_reference.md
CHANGED
|
@@ -22,7 +22,7 @@ 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
|
-
| `lotics upload <file\|dir...>` | Upload files/directories via multipart POST to /v1/files |
|
|
25
|
+
| `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
26
|
| `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
27
|
| `lotics knowledge list` | List knowledge docs via the `list_knowledge` tool — a table of id, name, description (`--json` for the raw results array). |
|
|
28
28
|
| `lotics knowledge create --name <n> [--description <d>] (--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 }` (description defaults to `""`). Prints the new id to stdout. Large files ride the POST body fine. |
|