@lotics/cli 0.28.0 → 0.30.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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * CLI argument parser.
3
+ *
4
+ * Splits `process.argv.slice(2)` into a command / subcommand / positional
5
+ * (`toolArgs`) / remaining-positionals (`restArgs`) shape plus typed `flags`.
6
+ * Value-taking flags (`-m`, `--api-key`, …) consume the next token; boolean
7
+ * flags (`--force-workflow-sync`, `--json`, …) toggle. Anything not matching a
8
+ * known flag is positional.
9
+ *
10
+ * Lives apart from `cli.ts` so it can be unit-tested — `cli.ts` runs `main()`
11
+ * on import, so importing the parser from there would execute the CLI.
12
+ */
13
+ export declare function parseArgs(argv: string[]): {
14
+ command?: string;
15
+ subcommand?: string;
16
+ toolArgs?: string;
17
+ restArgs: string[];
18
+ flags: {
19
+ json: boolean;
20
+ timeout?: number;
21
+ output?: string;
22
+ as?: string;
23
+ apiKey?: string;
24
+ name?: string;
25
+ timezone?: string;
26
+ message?: string;
27
+ forceWorkflowSync: boolean;
28
+ local: boolean;
29
+ version: boolean;
30
+ help: boolean;
31
+ };
32
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * CLI argument parser.
3
+ *
4
+ * Splits `process.argv.slice(2)` into a command / subcommand / positional
5
+ * (`toolArgs`) / remaining-positionals (`restArgs`) shape plus typed `flags`.
6
+ * Value-taking flags (`-m`, `--api-key`, …) consume the next token; boolean
7
+ * flags (`--force-workflow-sync`, `--json`, …) toggle. Anything not matching a
8
+ * known flag is positional.
9
+ *
10
+ * Lives apart from `cli.ts` so it can be unit-tested — `cli.ts` runs `main()`
11
+ * on import, so importing the parser from there would execute the CLI.
12
+ */
13
+ export function parseArgs(argv) {
14
+ const flags = {
15
+ json: false,
16
+ timeout: undefined,
17
+ output: undefined,
18
+ as: undefined,
19
+ apiKey: undefined,
20
+ name: undefined,
21
+ timezone: undefined,
22
+ message: undefined,
23
+ forceWorkflowSync: false,
24
+ local: false,
25
+ version: false,
26
+ help: false,
27
+ };
28
+ let command;
29
+ let subcommand;
30
+ let toolArgs;
31
+ const restArgs = [];
32
+ let i = 0;
33
+ while (i < argv.length) {
34
+ const arg = argv[i];
35
+ switch (arg) {
36
+ case "--json":
37
+ flags.json = true;
38
+ break;
39
+ case "--timeout":
40
+ flags.timeout = parseInt(argv[++i], 10);
41
+ break;
42
+ case "--output":
43
+ case "-o":
44
+ flags.output = argv[++i];
45
+ break;
46
+ case "--as":
47
+ flags.as = argv[++i];
48
+ break;
49
+ case "--api-key":
50
+ flags.apiKey = argv[++i];
51
+ break;
52
+ case "--name":
53
+ flags.name = argv[++i];
54
+ break;
55
+ case "--timezone":
56
+ flags.timezone = argv[++i];
57
+ break;
58
+ case "-m":
59
+ case "--message":
60
+ flags.message = argv[++i];
61
+ break;
62
+ case "--force-workflow-sync":
63
+ flags.forceWorkflowSync = true;
64
+ break;
65
+ case "--local":
66
+ flags.local = true;
67
+ break;
68
+ case "--version":
69
+ case "-v":
70
+ flags.version = true;
71
+ break;
72
+ case "--help":
73
+ case "-h":
74
+ flags.help = true;
75
+ break;
76
+ default:
77
+ if (!command) {
78
+ command = arg;
79
+ }
80
+ else if (!subcommand) {
81
+ subcommand = arg;
82
+ }
83
+ else if (!toolArgs) {
84
+ toolArgs = arg;
85
+ }
86
+ else {
87
+ restArgs.push(arg);
88
+ }
89
+ break;
90
+ }
91
+ i++;
92
+ }
93
+ return { command, subcommand, toolArgs, restArgs, flags };
94
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseArgs } from "./args.js";
3
+ describe("parseArgs", () => {
4
+ it("splits command / subcommand / positional", () => {
5
+ const r = parseArgs(["app", "deploy", "my message"]);
6
+ expect(r.command).toBe("app");
7
+ expect(r.subcommand).toBe("deploy");
8
+ expect(r.toolArgs).toBe("my message");
9
+ expect(r.restArgs).toEqual([]);
10
+ });
11
+ // `app deploy` regression: `-m` and `--force-workflow-sync` must be parsed
12
+ // as flags, not silently consumed as the positional message.
13
+ it("parses -m as the message flag", () => {
14
+ const r = parseArgs(["app", "deploy", "-m", "a message"]);
15
+ expect(r.flags.message).toBe("a message");
16
+ expect(r.toolArgs).toBeUndefined();
17
+ });
18
+ it("parses --message as the message flag", () => {
19
+ const r = parseArgs(["app", "deploy", "--message", "a message"]);
20
+ expect(r.flags.message).toBe("a message");
21
+ });
22
+ it("parses --force-workflow-sync as a boolean flag", () => {
23
+ const r = parseArgs(["app", "deploy", "msg", "--force-workflow-sync"]);
24
+ expect(r.flags.forceWorkflowSync).toBe(true);
25
+ expect(r.toolArgs).toBe("msg");
26
+ });
27
+ it("parses --force-workflow-sync before -m without eating the message", () => {
28
+ const r = parseArgs(["app", "deploy", "--force-workflow-sync", "-m", "msg"]);
29
+ expect(r.flags.forceWorkflowSync).toBe(true);
30
+ expect(r.flags.message).toBe("msg");
31
+ });
32
+ it("defaults forceWorkflowSync to false and message to undefined", () => {
33
+ const r = parseArgs(["app", "deploy"]);
34
+ expect(r.flags.forceWorkflowSync).toBe(false);
35
+ expect(r.flags.message).toBeUndefined();
36
+ });
37
+ });
package/dist/src/cli.js CHANGED
@@ -6,6 +6,7 @@ import { LoticsClient, API_BASE_URL } from "./client.js";
6
6
  import { resolveAuth, loadConfig, saveConfig, deleteConfig, getConfigPath, checkForUpdate } from "./config.js";
7
7
  import { VERSION } from "./version.js";
8
8
  import { appCreate, appPull, appDeploy, appDev } from "./app_commands.js";
9
+ import { parseArgs } from "./args.js";
9
10
  function printHelp() {
10
11
  console.log(`Lotics CLI v${VERSION} — AI agent interface for Lotics
11
12
 
@@ -114,79 +115,6 @@ else ~/.lotics/config.json. A per-directory config pins a project or worktree to
114
115
  its own account and workspace; --local creates one. Note: an exported
115
116
  LOTICS_API_KEY env var overrides the config file's key.`);
116
117
  }
117
- function parseArgs(argv) {
118
- const flags = {
119
- json: false,
120
- timeout: undefined,
121
- output: undefined,
122
- as: undefined,
123
- apiKey: undefined,
124
- name: undefined,
125
- timezone: undefined,
126
- local: false,
127
- version: false,
128
- help: false,
129
- };
130
- let command;
131
- let subcommand;
132
- let toolArgs;
133
- const restArgs = [];
134
- let i = 0;
135
- while (i < argv.length) {
136
- const arg = argv[i];
137
- switch (arg) {
138
- case "--json":
139
- flags.json = true;
140
- break;
141
- case "--timeout":
142
- flags.timeout = parseInt(argv[++i], 10);
143
- break;
144
- case "--output":
145
- case "-o":
146
- flags.output = argv[++i];
147
- break;
148
- case "--as":
149
- flags.as = argv[++i];
150
- break;
151
- case "--api-key":
152
- flags.apiKey = argv[++i];
153
- break;
154
- case "--name":
155
- flags.name = argv[++i];
156
- break;
157
- case "--timezone":
158
- flags.timezone = argv[++i];
159
- break;
160
- case "--local":
161
- flags.local = true;
162
- break;
163
- case "--version":
164
- case "-v":
165
- flags.version = true;
166
- break;
167
- case "--help":
168
- case "-h":
169
- flags.help = true;
170
- break;
171
- default:
172
- if (!command) {
173
- command = arg;
174
- }
175
- else if (!subcommand) {
176
- subcommand = arg;
177
- }
178
- else if (!toolArgs) {
179
- toolArgs = arg;
180
- }
181
- else {
182
- restArgs.push(arg);
183
- }
184
- break;
185
- }
186
- i++;
187
- }
188
- return { command, subcommand, toolArgs, restArgs, flags };
189
- }
190
118
  function readStdin() {
191
119
  return new Promise((resolve, reject) => {
192
120
  const chunks = [];
@@ -546,13 +474,11 @@ async function main() {
546
474
  return;
547
475
  }
548
476
  if (subcommand === "deploy") {
549
- // -m / --message can be passed via toolArgs or after a flag-like delimiter.
550
- // Keep it simple: any positional arg after `deploy` is treated as the message.
551
- // --force-workflow-sync is a separate flag captured upstream via restArgs
552
- // because it has no value (boolean toggle).
553
- const message = toolArgs;
554
- const forceWorkflowSync = restArgs.includes("--force-workflow-sync");
555
- await appDeploy(client, { message, forceWorkflowSync });
477
+ // The message is either `-m <message>` or a bare positional arg after
478
+ // `deploy`. `--force-workflow-sync` is a parsed boolean flag, valid in
479
+ // any position.
480
+ const message = flags.message ?? toolArgs;
481
+ await appDeploy(client, { message, forceWorkflowSync: flags.forceWorkflowSync });
556
482
  return;
557
483
  }
558
484
  if (subcommand === "dev") {
@@ -129,6 +129,36 @@ export declare class LoticsClient {
129
129
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
130
130
  */
131
131
  appWorkflow(app_id: string, alias: string, inputs: unknown): Promise<unknown>;
132
+ /**
133
+ * Mint a presigned URL for uploading a file into an app. Mirrors
134
+ * POST /v1/apps/{app_id}/files/upload-url.
135
+ */
136
+ appRequestFileUpload(app_id: string, body: {
137
+ filename: string;
138
+ mime_type: string;
139
+ file_size: number;
140
+ }): Promise<{
141
+ file_id: string;
142
+ file_storage_key: string;
143
+ upload_url: string;
144
+ }>;
145
+ /**
146
+ * Finalize a presigned upload once the bytes are in storage. Mirrors
147
+ * POST /v1/apps/{app_id}/files/complete.
148
+ */
149
+ appCompleteFileUpload(app_id: string, body: {
150
+ file_id: string;
151
+ file_storage_key: string;
152
+ filename: string;
153
+ }): Promise<{
154
+ file: {
155
+ id: string;
156
+ filename: string;
157
+ mime_type: string;
158
+ url?: string;
159
+ thumbnail_url?: string;
160
+ };
161
+ }>;
132
162
  deployAppVersion(args: {
133
163
  app_id: string;
134
164
  source_archive: Buffer;
@@ -175,6 +175,20 @@ export class LoticsClient {
175
175
  async appWorkflow(app_id, alias, inputs) {
176
176
  return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/execute`, { inputs });
177
177
  }
178
+ /**
179
+ * Mint a presigned URL for uploading a file into an app. Mirrors
180
+ * POST /v1/apps/{app_id}/files/upload-url.
181
+ */
182
+ async appRequestFileUpload(app_id, body) {
183
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/files/upload-url`, body);
184
+ }
185
+ /**
186
+ * Finalize a presigned upload once the bytes are in storage. Mirrors
187
+ * POST /v1/apps/{app_id}/files/complete.
188
+ */
189
+ async appCompleteFileUpload(app_id, body) {
190
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/files/complete`, body);
191
+ }
178
192
  async deployAppVersion(args) {
179
193
  const formData = new FormData();
180
194
  // Wrap Buffers as Uint8Array views so the Blob constructor accepts them
@@ -6,7 +6,7 @@
6
6
  * { message }. Same shape as the production iframe-host's error path.
7
7
  */
8
8
  import { LoticsClient } from "../client.js";
9
- export type RpcOp = "query" | "workflow";
9
+ export type RpcOp = "query" | "workflow" | "upload_url" | "upload_complete";
10
10
  export interface RpcRequest {
11
11
  app_id: string;
12
12
  op: RpcOp;
@@ -5,7 +5,12 @@
5
5
  * Errors are thrown — the HTTP server caller serializes them to a 500 with
6
6
  * { message }. Same shape as the production iframe-host's error path.
7
7
  */
8
- const SUPPORTED_OPS = new Set(["query", "workflow"]);
8
+ const SUPPORTED_OPS = new Set([
9
+ "query",
10
+ "workflow",
11
+ "upload_url",
12
+ "upload_complete",
13
+ ]);
9
14
  export async function dispatchRpc(client, body) {
10
15
  if (!body || typeof body.app_id !== "string" || typeof body.op !== "string") {
11
16
  throw new Error("RPC envelope must include app_id and op");
@@ -28,6 +33,34 @@ export async function dispatchRpc(client, body) {
28
33
  }
29
34
  return client.appWorkflow(body.app_id, p.alias, p.inputs);
30
35
  }
36
+ case "upload_url": {
37
+ const p = body.payload;
38
+ if (!p ||
39
+ typeof p.filename !== "string" ||
40
+ typeof p.mime_type !== "string" ||
41
+ typeof p.file_size !== "number") {
42
+ throw new Error("upload_url payload must include filename, mime_type, file_size");
43
+ }
44
+ return client.appRequestFileUpload(body.app_id, {
45
+ filename: p.filename,
46
+ mime_type: p.mime_type,
47
+ file_size: p.file_size,
48
+ });
49
+ }
50
+ case "upload_complete": {
51
+ const p = body.payload;
52
+ if (!p ||
53
+ typeof p.file_id !== "string" ||
54
+ typeof p.file_storage_key !== "string" ||
55
+ typeof p.filename !== "string") {
56
+ throw new Error("upload_complete payload must include file_id, file_storage_key, filename");
57
+ }
58
+ return client.appCompleteFileUpload(body.app_id, {
59
+ file_id: p.file_id,
60
+ file_storage_key: p.file_storage_key,
61
+ filename: p.filename,
62
+ });
63
+ }
31
64
  default: {
32
65
  // Unreachable — SUPPORTED_OPS gates above.
33
66
  throw new Error(`Unhandled RPC op: ${body.op}`);
@@ -59,24 +59,59 @@ export function buildWrapperPage(args) {
59
59
  const APP_ID = ${JSON.stringify(app_id)};
60
60
  const iframe = document.getElementById("app");
61
61
 
62
+ async function rpc(op, payload) {
63
+ const res = await fetch("/_rpc", {
64
+ method: "POST",
65
+ headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({ app_id: APP_ID, op: op, payload: payload }),
67
+ });
68
+ const text = await res.text();
69
+ if (!res.ok) {
70
+ let detail = text;
71
+ try { detail = JSON.parse(text).message ?? text; } catch (_) {}
72
+ throw new Error(detail || ("HTTP " + res.status));
73
+ }
74
+ return JSON.parse(text);
75
+ }
76
+
77
+ // The iframe SDK sends one "upload" op carrying a File. A File can't
78
+ // cross the JSON /_rpc hop, so the upload runs here in the browser:
79
+ // mint a presigned URL, PUT the bytes to storage, then finalize.
80
+ async function handleUpload(payload) {
81
+ const file = payload && payload.file;
82
+ if (!(file instanceof File)) {
83
+ throw new Error("upload payload must include a File");
84
+ }
85
+ const init = await rpc("upload_url", {
86
+ filename: file.name,
87
+ mime_type: file.type,
88
+ file_size: file.size,
89
+ });
90
+ const putRes = await fetch(init.upload_url, {
91
+ method: "PUT",
92
+ body: file,
93
+ headers: { "Content-Type": file.type },
94
+ });
95
+ if (!putRes.ok) {
96
+ throw new Error("Storage upload failed (" + putRes.status + ")");
97
+ }
98
+ const completed = await rpc("upload_complete", {
99
+ file_id: init.file_id,
100
+ file_storage_key: init.file_storage_key,
101
+ filename: file.name,
102
+ });
103
+ return completed.file;
104
+ }
105
+
62
106
  window.addEventListener("message", async function (event) {
63
107
  if (event.source !== iframe.contentWindow) return;
64
108
  const msg = event.data;
65
109
  if (!msg || typeof msg.id !== "number" || typeof msg.op !== "string") return;
66
110
  const startedAt = performance.now();
67
111
  try {
68
- const res = await fetch("/_rpc", {
69
- method: "POST",
70
- headers: { "content-type": "application/json" },
71
- body: JSON.stringify({ app_id: APP_ID, op: msg.op, payload: msg.payload }),
72
- });
73
- const text = await res.text();
74
- if (!res.ok) {
75
- let detail = text;
76
- try { detail = JSON.parse(text).message ?? text; } catch (_) {}
77
- throw new Error(detail || ("HTTP " + res.status));
78
- }
79
- const data = JSON.parse(text);
112
+ const data = msg.op === "upload"
113
+ ? await handleUpload(msg.payload)
114
+ : await rpc(msg.op, msg.payload);
80
115
  const ms = Math.round(performance.now() - startedAt);
81
116
  console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
82
117
  iframe.contentWindow.postMessage({ id: msg.id, type: "result", data: data }, "*");
@@ -111,6 +111,8 @@ function inputDeclToTsType(decl) {
111
111
  }
112
112
  case "date_range":
113
113
  return "{ start: string; end: string }";
114
+ case "file":
115
+ return "string";
114
116
  case "json":
115
117
  return "unknown";
116
118
  default:
@@ -10,8 +10,10 @@
10
10
  *
11
11
  * Conventions (locked decisions):
12
12
  * - Vite + React + TypeScript (strict mode).
13
- * - Entry: src/App.tsx with `export default`. main.tsx wires `mount(<App/>)`
14
- * and imports @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
13
+ * - Entry: src/App.tsx with `export default`. main.tsx wires
14
+ * `mount(<PortalHost><App/></PortalHost>)` PortalHost is the render
15
+ * target @lotics/ui overlays (Popover/Tooltip/Dialog) need — and imports
16
+ * @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
15
17
  * (path-independent Inter @font-face bundle — Text renders unstyled without it).
16
18
  * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
17
19
  * endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
@@ -10,8 +10,10 @@
10
10
  *
11
11
  * Conventions (locked decisions):
12
12
  * - Vite + React + TypeScript (strict mode).
13
- * - Entry: src/App.tsx with `export default`. main.tsx wires `mount(<App/>)`
14
- * and imports @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
13
+ * - Entry: src/App.tsx with `export default`. main.tsx wires
14
+ * `mount(<PortalHost><App/></PortalHost>)` PortalHost is the render
15
+ * target @lotics/ui overlays (Popover/Tooltip/Dialog) need — and imports
16
+ * @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
15
17
  * (path-independent Inter @font-face bundle — Text renders unstyled without it).
16
18
  * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
17
19
  * endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
@@ -48,7 +50,7 @@ export function buildStarterTemplate(args) {
48
50
  test: "vitest run",
49
51
  },
50
52
  dependencies: {
51
- "@lotics/app-sdk": "^0.5.0",
53
+ "@lotics/app-sdk": "^0.6.0",
52
54
  "@lotics/ui": "^1.3.0",
53
55
  "@react-native-picker/picker": "^2.7.0",
54
56
  "expo-image": "~3.0.9",
@@ -194,10 +196,17 @@ export default defineConfig({
194
196
  path: "src/main.tsx",
195
197
  content: `import "@lotics/ui/index.css";
196
198
  import "@lotics/ui/fonts.css";
199
+ import { PortalHost } from "@lotics/ui/portal";
197
200
  import { mount } from "@lotics/app-sdk";
198
201
  import App from "./App";
199
202
 
200
- mount(<App />);
203
+ // PortalHost is the render target for @lotics/ui overlays (Popover, Tooltip,
204
+ // Dialog). Without it, Portal renders nothing — keep it wrapping the app.
205
+ mount(
206
+ <PortalHost>
207
+ <App />
208
+ </PortalHost>,
209
+ );
201
210
  `,
202
211
  },
203
212
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {