@lotics/cli 0.9.0 → 0.12.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/README.md CHANGED
@@ -25,27 +25,27 @@ The CLI checks for updates once per day and prompts when a new version is availa
25
25
 
26
26
  ## Authentication
27
27
 
28
- **`lotics signup`** — Creates a new Lotics account, organization, workspace, and API key in one step. Sends a magic link email so you can access the web app.
28
+ **`lotics auth signup`** — Creates a new Lotics account, organization, workspace, and API key in one step. Sends a magic link email so you can access the web app.
29
29
 
30
30
  ```bash
31
- lotics signup # interactive prompts
32
- lotics signup --email a@b.com --name "Agent" # non-interactive
31
+ lotics auth signup # interactive prompts
32
+ lotics auth signup a@b.com --name "Agent" # non-interactive
33
33
  ```
34
34
 
35
- **`lotics login`** — Send a magic link email to access the web app (requires prior signup or setup).
35
+ **`lotics auth web`** — Send a magic link email to access the web app (requires prior signup or setup).
36
36
 
37
37
  ```bash
38
- lotics login
38
+ lotics auth web
39
39
  ```
40
40
 
41
- **`lotics setup`** — Saves an existing API key directly (e.g. one created in the Lotics web app).
41
+ **`lotics auth api-key`** — Saves an existing API key directly (e.g. one created in the Lotics web app).
42
42
 
43
43
  ```bash
44
- lotics setup # interactive prompt
45
- lotics setup ltk_... # non-interactive
44
+ lotics auth api-key # interactive prompt
45
+ lotics auth api-key ltk_... # non-interactive
46
46
  ```
47
47
 
48
- API key is saved to `~/.lotics/config.json`. Run `lotics logout` to remove saved credentials.
48
+ API key is saved to `~/.lotics/config.json`. Run `lotics auth logout` to remove saved credentials.
49
49
 
50
50
  Auth priority: `--api-key` flag > `LOTICS_API_KEY` env > `~/.lotics/config.json`.
51
51
 
package/dist/src/cli.js CHANGED
@@ -15,23 +15,11 @@ Lotics is an AI-powered operations platform. Through this CLI you can:
15
15
  - Create and manage apps, knowledge docs, and files
16
16
 
17
17
  AUTHENTICATION
18
- lotics signup Create account, org, workspace, and API key
19
- lotics login Send a magic link email to access the web app
20
- lotics setup [api_key] Save an existing API key (e.g. from the web app)
21
- lotics whoami Show the email of the current account
22
- lotics logout Remove saved credentials
23
-
24
- Signup flags:
25
- --email <email> Email (required; prompted if omitted)
26
- --name <name> Display name (defaults to email prefix)
27
- --timezone <timezone> Workspace timezone (defaults to UTC, e.g. Asia/Ho_Chi_Minh)
28
-
29
- Signup sends a magic link email so you can access the Lotics web app.
30
- Use lotics login to request a new magic link at any time.
31
- Setup priority: --api-key flag > LOTICS_API_KEY env > saved config.
18
+ lotics auth Show auth help
19
+ lotics auth signup <email> Create account (run "lotics auth" for details)
32
20
 
33
21
  USAGE
34
- 1. lotics signup (or lotics setup) Create account or set up API key
22
+ 1. lotics auth signup <email> Create account or authenticate
35
23
  2. lotics workspace Check current workspace (auto-selects if only one)
36
24
  3. lotics tools List available tools by category
37
25
  4. lotics tools <name> Show tool description and full input schema
@@ -50,6 +38,8 @@ COMMANDS
50
38
  lotics run <tool> '<json>' Execute a tool
51
39
  lotics upload <file|dir...> Upload files (directories expand to their immediate files)
52
40
  lotics download <file_id> Download a file by ID
41
+ lotics download record <record_id> <field_key>
42
+ Download all files on a record file field
53
43
 
54
44
  FLAGS
55
45
  --json Full JSON output (default is human-readable text)
@@ -60,9 +50,10 @@ FLAGS
60
50
  --version Show version
61
51
 
62
52
  OUTPUT
63
- Default output is a human-readable text summary. Use --json to get
64
- structured JSON for programmatic use. Errors print to stderr and
65
- exit with code 1.
53
+ Default output is a compact text summary optimized for AI agents —
54
+ use it directly, no parsing needed. --json returns raw structured
55
+ JSON for machine-to-machine pipelines (scripts, CI). Errors print
56
+ to stderr and exit with code 1.
66
57
 
67
58
  FILES
68
59
  Some tools generate files and return { file_id, url, filename }.
@@ -81,6 +72,23 @@ STDIN
81
72
 
82
73
  echo '{"table_id":"tbl_..."}' | lotics run query_records`);
83
74
  }
75
+ function printAuthHelp() {
76
+ console.log(`Authentication commands:
77
+
78
+ lotics auth signup <email> Create account, org, workspace, and API key
79
+ lotics auth api-key [key] Save an existing API key (e.g. from the web app)
80
+ lotics auth web Send a magic link email to access the web app
81
+ lotics auth whoami Show the email of the current account
82
+ lotics auth logout Remove saved credentials
83
+
84
+ Signup flags:
85
+ --name <name> Display name (defaults to email prefix)
86
+ --timezone <timezone> Workspace timezone (defaults to UTC, e.g. Asia/Ho_Chi_Minh)
87
+
88
+ Signup sends a magic link email so you can access the Lotics web app.
89
+ Use lotics auth web to request a new magic link at any time.
90
+ Auth priority: --api-key flag > LOTICS_API_KEY env > saved config.`);
91
+ }
84
92
  function parseArgs(argv) {
85
93
  const flags = {
86
94
  json: false,
@@ -88,7 +96,6 @@ function parseArgs(argv) {
88
96
  output: undefined,
89
97
  as: undefined,
90
98
  apiKey: undefined,
91
- email: undefined,
92
99
  name: undefined,
93
100
  timezone: undefined,
94
101
  version: false,
@@ -118,9 +125,6 @@ function parseArgs(argv) {
118
125
  case "--api-key":
119
126
  flags.apiKey = argv[++i];
120
127
  break;
121
- case "--email":
122
- flags.email = argv[++i];
123
- break;
124
128
  case "--name":
125
129
  flags.name = argv[++i];
126
130
  break;
@@ -183,10 +187,10 @@ async function publicPost(path, body) {
183
187
  const data = await response.json();
184
188
  return { ok: response.ok, status: response.status, data };
185
189
  }
186
- async function handleSignup(flags) {
187
- const email = flags.email ?? (process.stdin.isTTY ? await prompt("Email: ") : "");
190
+ async function handleSignup(positionalEmail, flags) {
191
+ const email = positionalEmail ?? (process.stdin.isTTY ? await prompt("Email: ") : "");
188
192
  if (!email) {
189
- console.error("Email is required. Use --email or run interactively.");
193
+ console.error("Email is required. Usage: lotics auth signup <email>");
190
194
  process.exit(1);
191
195
  }
192
196
  const name = flags.name ?? (process.stdin.isTTY ? await prompt("Name (enter to use email): ") : undefined);
@@ -198,7 +202,7 @@ async function handleSignup(flags) {
198
202
  const { ok, status, data } = await publicPost("/v1/cli/signup", body);
199
203
  if (!ok) {
200
204
  if (status === 409) {
201
- console.error("An account with this email already exists. Use: lotics setup");
205
+ console.error("An account with this email already exists. Use: lotics auth api-key");
202
206
  }
203
207
  else if (status === 429) {
204
208
  console.error("Too many signup attempts. Try again later.");
@@ -218,7 +222,7 @@ async function handleSignup(flags) {
218
222
  console.error(`Account created. You can now use the CLI.`);
219
223
  console.error(` Email: ${data.email}`);
220
224
  console.error(`\nCheck your email for a magic link to access the Lotics web app.`);
221
- console.error(`Run "lotics login" to request a new link at any time.`);
225
+ console.error(`Run "lotics auth web" to request a new link at any time.`);
222
226
  }
223
227
  async function handleSetup(providedKey) {
224
228
  const apiKey = providedKey ?? await prompt("Enter your API key: ");
@@ -260,7 +264,7 @@ async function handleSetup(providedKey) {
260
264
  function requireClient(flags) {
261
265
  const auth = resolveAuth(flags);
262
266
  if (!auth) {
263
- console.error('Not authenticated. Run "lotics setup" or set LOTICS_API_KEY.');
267
+ console.error('Not authenticated. Run "lotics auth signup" or set LOTICS_API_KEY.');
264
268
  process.exit(1);
265
269
  }
266
270
  const config = loadConfig();
@@ -334,51 +338,58 @@ async function main() {
334
338
  console.log(VERSION);
335
339
  return;
336
340
  }
337
- // --- Commands that don't require auth ---
338
- if (command === "signup") {
339
- await handleSignup(flags);
340
- return;
341
- }
342
- if (command === "setup") {
343
- await handleSetup(subcommand ?? flags.apiKey);
344
- return;
345
- }
346
- if (command === "whoami") {
347
- const config = loadConfig();
348
- if (config?.email) {
349
- console.log(config.email);
341
+ // --- lotics auth <subcommand> ---
342
+ if (command === "auth") {
343
+ if (subcommand === "signup") {
344
+ await handleSignup(toolArgs, flags);
350
345
  return;
351
346
  }
352
- // No email cached — try fetching from API
353
- const auth = resolveAuth(flags);
354
- if (!auth) {
355
- console.error('Not authenticated. Run "lotics setup" or set LOTICS_API_KEY.');
356
- process.exit(1);
347
+ if (subcommand === "api-key") {
348
+ await handleSetup(toolArgs ?? flags.apiKey);
349
+ return;
357
350
  }
358
- const client = new LoticsClient({ apiKey: auth.apiKey });
359
- try {
360
- const info = await client.whoami();
361
- // Cache for next time
362
- const existing = loadConfig() ?? {};
363
- saveConfig({ ...existing, email: info.email });
364
- console.log(info.email);
365
- }
366
- catch (error) {
367
- const message = error instanceof Error ? error.message : String(error);
368
- console.error(`Failed to fetch account info: ${message}`);
369
- process.exit(1);
351
+ if (subcommand === "whoami") {
352
+ const config = loadConfig();
353
+ if (config?.email) {
354
+ console.log(config.email);
355
+ return;
356
+ }
357
+ // No email cached — try fetching from API
358
+ const auth = resolveAuth(flags);
359
+ if (!auth) {
360
+ console.error('Not authenticated. Run "lotics auth signup" or set LOTICS_API_KEY.');
361
+ process.exit(1);
362
+ }
363
+ const client = new LoticsClient({ apiKey: auth.apiKey });
364
+ try {
365
+ const info = await client.whoami();
366
+ // Cache for next time
367
+ const existing = loadConfig() ?? {};
368
+ saveConfig({ ...existing, email: info.email });
369
+ console.log(info.email);
370
+ }
371
+ catch (error) {
372
+ const message = error instanceof Error ? error.message : String(error);
373
+ console.error(`Failed to fetch account info: ${message}`);
374
+ process.exit(1);
375
+ }
376
+ return;
370
377
  }
371
- return;
372
- }
373
- if (command === "logout") {
374
- deleteConfig();
375
- console.error("Logged out. Credentials removed.");
376
- return;
377
- }
378
- if (command === "login") {
379
- const client = requireClient(flags);
380
- const { email } = await client.login();
381
- console.error(`Magic link sent to ${email}. Check your email to access the Lotics web app.`);
378
+ if (subcommand === "logout") {
379
+ deleteConfig();
380
+ console.error("Logged out. Credentials removed.");
381
+ return;
382
+ }
383
+ if (subcommand === "web") {
384
+ const client = requireClient(flags);
385
+ const { email } = await client.login();
386
+ console.error(`Magic link sent to ${email}. Check your email to access the Lotics web app.`);
387
+ return;
388
+ }
389
+ if (subcommand) {
390
+ console.error(`Unknown auth subcommand: ${subcommand}\n`);
391
+ }
392
+ printAuthHelp();
382
393
  return;
383
394
  }
384
395
  // --- Validate command before auth ---
@@ -398,10 +409,17 @@ async function main() {
398
409
  process.exit(1);
399
410
  }
400
411
  if (command === "download" && !subcommand) {
401
- console.error('Usage: lotics download <file_id> [-o <dir>]');
412
+ console.error('Usage:');
413
+ console.error(' lotics download <file_id> [-o <dir>]');
414
+ console.error(' lotics download record <record_id> <field_key> [-o <dir>]');
402
415
  console.error('File IDs come from upload results or generate_* tool output (--json).');
403
416
  process.exit(1);
404
417
  }
418
+ if (command === "download" && subcommand === "record" && (!toolArgs || restArgs.length === 0)) {
419
+ console.error('Usage: lotics download record <record_id> <field_key> [-o <dir>]');
420
+ console.error('Downloads every file on the given file field into the output dir.');
421
+ process.exit(1);
422
+ }
405
423
  const client = requireClient(flags);
406
424
  // lotics workspace / lotics workspace list / lotics workspace select <id>
407
425
  if (command === "workspace") {
@@ -528,7 +546,23 @@ async function main() {
528
546
  return;
529
547
  }
530
548
  // lotics download <file_id> [-o <path>]
549
+ // lotics download record <record_id> <field_key> [-o <path>]
531
550
  if (command === "download") {
551
+ if (subcommand === "record") {
552
+ const recordId = toolArgs;
553
+ const fieldKey = restArgs[0];
554
+ const files = await client.downloadRecordFiles(recordId, fieldKey, flags.output);
555
+ if (flags.json) {
556
+ console.log(JSON.stringify(files, null, 2));
557
+ }
558
+ else {
559
+ for (const f of files) {
560
+ console.log(`${f.file_id} ${f.path} ${f.filename}`);
561
+ }
562
+ console.error(`Downloaded ${files.length} file${files.length === 1 ? "" : "s"} from ${recordId}.${fieldKey}`);
563
+ }
564
+ return;
565
+ }
532
566
  const { path: filePath, filename } = await client.downloadFileById(subcommand, flags.output);
533
567
  console.error(`Downloaded: ${filePath} (${filename})`);
534
568
  return;
@@ -67,10 +67,17 @@ export declare class LoticsClient {
67
67
  timeoutMs?: number;
68
68
  }): Promise<ToolExecuteResult>;
69
69
  downloadFile(url: string, outputPath: string): Promise<string>;
70
- downloadFileById(fileId: string, outputDir?: string): Promise<{
70
+ downloadFileById(fileId: string, outputDir?: string, options?: {
71
+ reserved?: Set<string>;
72
+ }): Promise<{
71
73
  path: string;
72
74
  filename: string;
73
75
  }>;
76
+ downloadRecordFiles(recordId: string, fieldKey: string, outputDir?: string): Promise<Array<{
77
+ path: string;
78
+ filename: string;
79
+ file_id: string;
80
+ }>>;
74
81
  uploadFiles(filePaths: string[], options?: {
75
82
  filenames?: string[];
76
83
  }): Promise<FileUploadResult>;
@@ -1,5 +1,29 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ function findAvailableFilename(dir, filename, reserved) {
4
+ // `reserved` tracks absolute paths claimed by in-flight downloads in the same
5
+ // batch — required for parallel callers because the file may not be on disk
6
+ // yet when a peer call races to pick a name.
7
+ const isTaken = (name) => {
8
+ const full = path.join(dir, name);
9
+ if (reserved?.has(full))
10
+ return true;
11
+ return fs.existsSync(full);
12
+ };
13
+ const claim = (name) => {
14
+ reserved?.add(path.join(dir, name));
15
+ return name;
16
+ };
17
+ if (!isTaken(filename))
18
+ return claim(filename);
19
+ const lastDot = filename.lastIndexOf(".");
20
+ const base = lastDot > 0 ? filename.slice(0, lastDot) : filename;
21
+ const ext = lastDot > 0 ? filename.slice(lastDot) : "";
22
+ let n = 2;
23
+ while (isTaken(`${base}_${n}${ext}`))
24
+ n++;
25
+ return claim(`${base}_${n}${ext}`);
26
+ }
3
27
  const MIME_MAP = {
4
28
  ".jpg": "image/jpeg",
5
29
  ".jpeg": "image/jpeg",
@@ -125,7 +149,7 @@ export class LoticsClient {
125
149
  await fs.promises.writeFile(absolutePath, buffer);
126
150
  return absolutePath;
127
151
  }
128
- async downloadFileById(fileId, outputDir) {
152
+ async downloadFileById(fileId, outputDir, options) {
129
153
  const url = `${this.baseUrl}/v1/files/${encodeURIComponent(fileId)}/download`;
130
154
  const response = await fetch(url, {
131
155
  headers: this.buildHeaders(),
@@ -134,13 +158,35 @@ export class LoticsClient {
134
158
  await this.throwResponseError(response);
135
159
  const disposition = response.headers.get("content-disposition") ?? "";
136
160
  const match = disposition.match(/filename="?([^";\n]+)"?/);
137
- const filename = match?.[1] ?? fileId;
161
+ const originalFilename = match?.[1] ?? fileId;
138
162
  const buffer = Buffer.from(await response.arrayBuffer());
139
163
  const dir = outputDir ? path.resolve(outputDir) : process.cwd();
164
+ const filename = findAvailableFilename(dir, originalFilename, options?.reserved);
140
165
  const absolutePath = path.join(dir, filename);
141
166
  await fs.promises.writeFile(absolutePath, buffer);
142
167
  return { path: absolutePath, filename };
143
168
  }
169
+ async downloadRecordFiles(recordId, fieldKey, outputDir) {
170
+ const result = await this.execute("get_record", { record_id: recordId }, { format: "text" });
171
+ if (result.error)
172
+ throw new Error(result.error);
173
+ const record = result.result;
174
+ const files = record?.data?.[fieldKey];
175
+ if (!Array.isArray(files)) {
176
+ throw new Error(`Field ${fieldKey} on ${recordId} is not a file field or has no value`);
177
+ }
178
+ const fileIds = files.map((f, i) => {
179
+ if (typeof f !== "object" || f === null || !("id" in f) || typeof f.id !== "string") {
180
+ throw new Error(`Invalid file entry [${i}] in ${recordId}.${fieldKey}: ${JSON.stringify(f)}`);
181
+ }
182
+ return f.id;
183
+ });
184
+ const reserved = new Set();
185
+ return Promise.all(fileIds.map(async (fileId) => {
186
+ const res = await this.downloadFileById(fileId, outputDir, { reserved });
187
+ return { ...res, file_id: fileId };
188
+ }));
189
+ }
144
190
  async uploadFiles(filePaths, options) {
145
191
  const formData = new FormData();
146
192
  for (let i = 0; i < filePaths.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.9.0",
3
+ "version": "0.12.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {