@genspark/cli 1.0.3 → 1.0.5

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/index.js CHANGED
@@ -18,32 +18,34 @@
18
18
  * Tool name: "web_search" -> Command: `gsk web_search`, Alias: `gsk search`
19
19
  * Tool name: "crawler" -> Command: `gsk crawler`, Alias: `gsk crawl`
20
20
  */
21
- import { Command } from "commander";
22
- import * as fs from "fs";
23
- import * as pathModule from "path";
24
- import { spawn } from "child_process";
25
- import { ApiClient, unauthenticatedRequest } from "./client.js";
26
- import { setDebugEnabled, setOutputFormat, debug, info, error as logError, output, } from "./logger.js";
27
- import { loadConfigFile, saveConfigFile, getConfigPath, loadToolsCache, saveToolsCache, } from "./config.js";
28
- import { checkForUpdates } from "./updater.js";
29
- const VERSION = "1.1.0";
30
- const DEFAULT_BASE_URL = "https://www.genspark.ai";
21
+ import { Command } from 'commander';
22
+ import * as fs from 'fs';
23
+ import * as http from 'http';
24
+ import * as https from 'https';
25
+ import * as pathModule from 'path';
26
+ import { spawn } from 'child_process';
27
+ import { ApiClient, unauthenticatedRequest } from './client.js';
28
+ import { setDebugEnabled, setOutputFormat, debug, info, error as logError, output, } from './logger.js';
29
+ import { loadConfigFile, saveConfigFile, getConfigPath, loadToolsCache, saveToolsCache, } from './config.js';
30
+ import { checkForUpdates } from './updater.js';
31
+ const VERSION = '1.1.0';
32
+ const DEFAULT_BASE_URL = 'https://www.genspark.ai';
31
33
  const DEFAULT_TIMEOUT = 300000; // 5 minutes (video generation can be slow)
32
34
  // Load config file (lowest priority)
33
35
  const fileConfig = loadConfigFile();
34
36
  // Create main program
35
37
  const program = new Command();
36
38
  program
37
- .name("gsk")
38
- .description("Genspark Tool CLI - Search, crawl, analyze, and generate media")
39
+ .name('gsk')
40
+ .description('Genspark Tool CLI - Search, crawl, analyze, and generate media')
39
41
  .version(VERSION)
40
- .option("--api-key <key>", "API key (or set GSK_API_KEY env var)")
41
- .option("--base-url <url>", `Base URL for API (default: ${DEFAULT_BASE_URL})`, DEFAULT_BASE_URL)
42
- .option("--project-id <id>", "Project ID for access control (or set GSK_PROJECT_ID env var)")
43
- .option("--debug", "Enable debug output and request more details from server", false)
44
- .option("--timeout <ms>", "Request timeout in milliseconds", String(DEFAULT_TIMEOUT))
45
- .option("--output <format>", "Output format: json or text", "json")
46
- .option("--refresh", "Force refresh of cached tool schemas from server");
42
+ .option('--api-key <key>', 'API key (or set GSK_API_KEY env var)')
43
+ .option('--base-url <url>', `Base URL for API (default: ${DEFAULT_BASE_URL})`, DEFAULT_BASE_URL)
44
+ .option('--project-id <id>', 'Project ID for access control (or set GSK_PROJECT_ID env var)')
45
+ .option('--debug', 'Enable debug output and request more details from server', false)
46
+ .option('--timeout <ms>', 'Request timeout in milliseconds', String(DEFAULT_TIMEOUT))
47
+ .option('--output <format>', 'Output format: json or text', 'json')
48
+ .option('--refresh', 'Force refresh of cached tool schemas from server');
47
49
  /**
48
50
  * Get global options with priority:
49
51
  * 1. Command-line options (highest)
@@ -81,75 +83,193 @@ function createClient() {
81
83
  setDebugEnabled(options.debug);
82
84
  setOutputFormat(options.output);
83
85
  if (!options.apiKey) {
84
- logError("API key is required. Provide it via:");
85
- logError(" 1. --api-key option");
86
- logError(" 2. GSK_API_KEY environment variable");
86
+ logError('API key is required. Provide it via:');
87
+ logError(' 1. --api-key option');
88
+ logError(' 2. GSK_API_KEY environment variable');
87
89
  logError(` 3. Config file: ${getConfigPath()}`);
88
90
  process.exit(1);
89
91
  }
90
92
  debug(`Base URL: ${options.baseUrl}`);
91
93
  debug(`Timeout: ${options.timeout}ms`);
92
94
  debug(`Debug mode: ${options.debug}`);
93
- debug(`Project ID: ${options.projectId || "(not set)"}`);
95
+ debug(`Project ID: ${options.projectId || '(not set)'}`);
94
96
  return new ApiClient(options);
95
97
  }
96
98
  // ============================================
97
99
  // Shared helpers for local file handling
98
100
  // ============================================
99
101
  const CONTENT_TYPE_MAP = {
100
- ".jpg": "image/jpeg",
101
- ".jpeg": "image/jpeg",
102
- ".png": "image/png",
103
- ".gif": "image/gif",
104
- ".webp": "image/webp",
105
- ".pdf": "application/pdf",
106
- ".txt": "text/plain",
107
- ".json": "application/json",
108
- ".html": "text/html",
109
- ".css": "text/css",
110
- ".js": "application/javascript",
111
- ".ts": "application/typescript",
112
- ".md": "text/markdown",
113
- ".xml": "application/xml",
114
- ".csv": "text/csv",
115
- ".zip": "application/zip",
116
- ".mp3": "audio/mpeg",
117
- ".mp4": "video/mp4",
118
- ".wav": "audio/wav",
119
- ".webm": "video/webm",
102
+ '.jpg': 'image/jpeg',
103
+ '.jpeg': 'image/jpeg',
104
+ '.png': 'image/png',
105
+ '.gif': 'image/gif',
106
+ '.webp': 'image/webp',
107
+ '.pdf': 'application/pdf',
108
+ '.txt': 'text/plain',
109
+ '.json': 'application/json',
110
+ '.html': 'text/html',
111
+ '.css': 'text/css',
112
+ '.js': 'application/javascript',
113
+ '.ts': 'application/typescript',
114
+ '.md': 'text/markdown',
115
+ '.xml': 'application/xml',
116
+ '.csv': 'text/csv',
117
+ '.zip': 'application/zip',
118
+ '.mp3': 'audio/mpeg',
119
+ '.mp4': 'video/mp4',
120
+ '.wav': 'audio/wav',
121
+ '.webm': 'video/webm',
120
122
  };
123
+ /**
124
+ * Stream a local file to a URL using Node.js http/https.request +
125
+ * fs.createReadStream so the file is never fully buffered in memory.
126
+ * Suitable for files of any size.
127
+ *
128
+ * Reports upload progress to stderr for files larger than PROGRESS_THRESHOLD.
129
+ * Returns the response body as a string (empty for typical blob-storage PUTs).
130
+ */
131
+ async function streamFileUpload(filePath, uploadUrl, headers, fileSize, method = 'PUT') {
132
+ const PROGRESS_THRESHOLD = 5 * 1024 * 1024; // 5 MB
133
+ const showProgress = fileSize > PROGRESS_THRESHOLD;
134
+ return new Promise((resolve, reject) => {
135
+ const parsedUrl = new URL(uploadUrl);
136
+ const isHttps = parsedUrl.protocol === 'https:';
137
+ const transport = isHttps ? https : http;
138
+ // Declare before transport.request so closures below don't reference it
139
+ // in the temporal dead zone.
140
+ let progressTimer = null;
141
+ const req = transport.request({
142
+ hostname: parsedUrl.hostname,
143
+ port: parsedUrl.port
144
+ ? parseInt(parsedUrl.port, 10)
145
+ : isHttps
146
+ ? 443
147
+ : 80,
148
+ path: parsedUrl.pathname + parsedUrl.search,
149
+ method,
150
+ headers: { ...headers, 'Content-Length': String(fileSize) },
151
+ }, res => {
152
+ // Always collect the full response body before resolving/rejecting
153
+ let body = '';
154
+ res.on('data', (chunk) => {
155
+ body += chunk.toString();
156
+ });
157
+ res.on('end', () => {
158
+ if (progressTimer)
159
+ clearInterval(progressTimer);
160
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
161
+ resolve(body);
162
+ }
163
+ else {
164
+ // Stop the file stream and close the request on non-2xx so we
165
+ // don't keep piping data into a request the server has rejected.
166
+ fileStream.destroy();
167
+ req.destroy();
168
+ reject(new Error(`Upload failed: HTTP ${res.statusCode}${body ? ': ' + body.substring(0, 200) : ''}`));
169
+ }
170
+ });
171
+ res.on('error', (err) => {
172
+ if (progressTimer)
173
+ clearInterval(progressTimer);
174
+ reject(err);
175
+ });
176
+ });
177
+ const fileStream = fs.createReadStream(filePath);
178
+ req.on('error', (err) => {
179
+ if (progressTimer)
180
+ clearInterval(progressTimer);
181
+ fileStream.destroy(); // prevent the readable from continuing to push data
182
+ reject(err);
183
+ });
184
+ let uploadedBytes = 0;
185
+ if (showProgress) {
186
+ const totalMB = (fileSize / (1024 * 1024)).toFixed(1);
187
+ progressTimer = setInterval(() => {
188
+ const pct = Math.floor((uploadedBytes / fileSize) * 100);
189
+ const doneMB = (uploadedBytes / (1024 * 1024)).toFixed(1);
190
+ process.stderr.write(`\r[INFO] Uploading... ${pct}% ${doneMB} / ${totalMB} MB `);
191
+ }, 1000);
192
+ }
193
+ fileStream.on('data', (chunk) => {
194
+ uploadedBytes +=
195
+ typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length;
196
+ });
197
+ fileStream.on('end', () => {
198
+ if (progressTimer) {
199
+ clearInterval(progressTimer);
200
+ process.stderr.write('\r\x1b[K'); // clear the progress line
201
+ }
202
+ });
203
+ fileStream.on('error', (err) => {
204
+ if (progressTimer)
205
+ clearInterval(progressTimer);
206
+ req.destroy(); // close the HTTP request so the TCP connection is not leaked
207
+ reject(err);
208
+ });
209
+ fileStream.pipe(req);
210
+ });
211
+ }
212
+ function formatFileSize(bytes) {
213
+ if (bytes >= 1024 * 1024)
214
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
215
+ return `${(bytes / 1024).toFixed(1)} KB`;
216
+ }
121
217
  async function uploadLocalFile(filePath, client) {
122
218
  const fileName = pathModule.basename(filePath);
123
219
  const ext = pathModule.extname(filePath).toLowerCase();
124
- const contentType = CONTENT_TYPE_MAP[ext] || "application/octet-stream";
125
- const stats = fs.statSync(filePath);
126
- info(`Uploading ${fileName} (${(stats.size / 1024).toFixed(1)} KB, ${contentType})...`);
220
+ const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
221
+ const fileSize = fs.statSync(filePath).size;
222
+ info(`Uploading ${fileName} (${formatFileSize(fileSize)}, ${contentType})...`);
127
223
  const globalOpts = getGlobalOptions();
128
224
  const uploadUrlResult = await client.getUploadUrl({
129
225
  content_type: contentType,
130
226
  name: fileName,
131
227
  project_id: globalOpts.projectId,
132
228
  });
133
- if (uploadUrlResult.status !== "ok" || !uploadUrlResult.data) {
229
+ if (uploadUrlResult.status !== 'ok' || !uploadUrlResult.data) {
134
230
  throw new Error(`Failed to get upload URL: ${uploadUrlResult.message}`);
135
231
  }
136
232
  const { upload_url, file_wrapper_url } = uploadUrlResult.data;
137
233
  debug(`Upload URL: ${upload_url.substring(0, 80)}...`);
138
- const fileBuffer = fs.readFileSync(filePath);
139
- const uploadResponse = await fetch(upload_url, {
140
- method: "PUT",
141
- headers: { "Content-Type": contentType, "x-ms-blob-type": "BlockBlob" },
142
- body: fileBuffer,
143
- });
144
- if (!uploadResponse.ok)
145
- throw new Error(`Upload failed: HTTP ${uploadResponse.status}`);
234
+ // Stream the file directly to Azure Blob Storage — no full-file buffering
235
+ await streamFileUpload(filePath, upload_url, { 'Content-Type': contentType, 'x-ms-blob-type': 'BlockBlob' }, fileSize);
146
236
  info(`Uploaded → ${file_wrapper_url}`);
147
237
  return file_wrapper_url;
148
238
  }
239
+ /**
240
+ * Upload a local file directly to AI Drive using the dedicated tool_cli
241
+ * endpoint (POST /api/tool_cli/aidrive/upload). The file is streamed without
242
+ * buffering the whole content in memory, so large files (100 MB+) work fine.
243
+ *
244
+ * This avoids the roundabout blob-upload + download_file two-step approach.
245
+ */
246
+ async function uploadToAiDrive(filePath, uploadPath, override = false) {
247
+ const fileName = pathModule.basename(filePath);
248
+ const ext = pathModule.extname(filePath).toLowerCase();
249
+ const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
250
+ const fileSize = fs.statSync(filePath).size;
251
+ info(`Uploading ${fileName} (${formatFileSize(fileSize)}, ${contentType}) directly to AI Drive...`);
252
+ const globalOpts = getGlobalOptions();
253
+ const baseUrl = globalOpts.baseUrl.replace(/\/$/, '');
254
+ const uploadUrl = `${baseUrl}/api/tool_cli/aidrive/upload?upload_path=${encodeURIComponent(uploadPath)}${override ? '&override=true' : ''}`;
255
+ const headers = { 'Content-Type': contentType };
256
+ if (globalOpts.apiKey)
257
+ headers['X-Api-Key'] = globalOpts.apiKey;
258
+ if (globalOpts.projectId)
259
+ headers['X-Project-ID'] = globalOpts.projectId;
260
+ debug(`AI Drive upload URL: ${uploadUrl}`);
261
+ const responseBody = await streamFileUpload(filePath, uploadUrl, headers, fileSize, 'POST');
262
+ try {
263
+ return JSON.parse(responseBody);
264
+ }
265
+ catch {
266
+ throw new Error(`Unexpected response from AI Drive upload: ${responseBody.substring(0, 200)}`);
267
+ }
268
+ }
149
269
  async function downloadToFile(wrapperUrl, savePath, client) {
150
270
  info(`Downloading to ${savePath}...`);
151
271
  const result = await client.getDownloadUrl({ file_wrapper_url: wrapperUrl });
152
- if (result.status !== "ok" || !result.data)
272
+ if (result.status !== 'ok' || !result.data)
153
273
  throw new Error(`Failed to get download URL: ${result.message}`);
154
274
  const resp = await fetch(result.data.download_url);
155
275
  if (!resp.ok)
@@ -160,9 +280,9 @@ async function downloadToFile(wrapperUrl, savePath, client) {
160
280
  return pathModule.resolve(savePath);
161
281
  }
162
282
  function isLocalFilePath(value) {
163
- return (!value.startsWith("http://") &&
164
- !value.startsWith("https://") &&
165
- !value.startsWith("/api/") &&
283
+ return (!value.startsWith('http://') &&
284
+ !value.startsWith('https://') &&
285
+ !value.startsWith('/api/') &&
166
286
  fs.existsSync(value));
167
287
  }
168
288
  async function resolveFileArg(value, client) {
@@ -179,10 +299,10 @@ async function resolveLocalFiles(args, client) {
179
299
  const resolved = { ...args };
180
300
  for (const [key, value] of Object.entries(resolved)) {
181
301
  // Handle array of URLs (e.g., image_urls)
182
- if (key.endsWith("_urls") && Array.isArray(value)) {
302
+ if (key.endsWith('_urls') && Array.isArray(value)) {
183
303
  const resolvedUrls = [];
184
304
  for (const item of value) {
185
- if (typeof item === "string") {
305
+ if (typeof item === 'string') {
186
306
  resolvedUrls.push(await resolveFileArg(item, client));
187
307
  }
188
308
  else {
@@ -192,8 +312,8 @@ async function resolveLocalFiles(args, client) {
192
312
  resolved[key] = resolvedUrls;
193
313
  }
194
314
  // Handle single URL (e.g., url, audio_url)
195
- else if ((key === "url" || key.endsWith("_url")) &&
196
- typeof value === "string") {
315
+ else if ((key === 'url' || key.endsWith('_url')) &&
316
+ typeof value === 'string') {
197
317
  resolved[key] = await resolveFileArg(value, client);
198
318
  }
199
319
  }
@@ -201,6 +321,9 @@ async function resolveLocalFiles(args, client) {
201
321
  }
202
322
  // ============================================
203
323
  // Dynamic tool command registration
324
+ // Map of group name -> parent Command for hierarchical subcommands
325
+ // (e.g., "email" -> Command for `gsk email [subcommand]`)
326
+ const _groupCommands = new Map();
204
327
  // ============================================
205
328
  /**
206
329
  * Extract a file URL from nested result data using dot-separated path keys.
@@ -209,14 +332,14 @@ async function resolveLocalFiles(args, client) {
209
332
  function extractFileUrl(data, keys) {
210
333
  for (const key of keys) {
211
334
  let current = data;
212
- for (const part of key.split(".")) {
213
- if (current == null || typeof current !== "object") {
335
+ for (const part of key.split('.')) {
336
+ if (current == null || typeof current !== 'object') {
214
337
  current = undefined;
215
338
  break;
216
339
  }
217
340
  current = current[part];
218
341
  }
219
- if (typeof current === "string" && current.length > 0)
342
+ if (typeof current === 'string' && current.length > 0)
220
343
  return current;
221
344
  }
222
345
  return null;
@@ -225,7 +348,27 @@ function extractFileUrl(data, keys) {
225
348
  * Register a dynamic tool command from a server-provided schema
226
349
  */
227
350
  function registerToolCommand(parentProgram, tool, clientFactory) {
228
- const cmd = parentProgram.command(tool.name).description(tool.description);
351
+ // If the tool has a group, register as a subcommand under the group parent
352
+ // e.g., tool.name="email_list" with cli.group="email" creates `gsk email list`
353
+ let targetProgram = parentProgram;
354
+ let cmdName = tool.name;
355
+ if (tool.cli.group) {
356
+ const groupName = tool.cli.group;
357
+ if (!_groupCommands.has(groupName)) {
358
+ const groupCmd = parentProgram
359
+ .command(groupName)
360
+ .description(`Commands for ${groupName}`);
361
+ _groupCommands.set(groupName, groupCmd);
362
+ }
363
+ targetProgram = _groupCommands.get(groupName);
364
+ // Derive subcommand name: strip group prefix from tool name
365
+ // e.g., "email_list" with group "email" -> "list"
366
+ const prefix = groupName + '_';
367
+ cmdName = tool.name.startsWith(prefix)
368
+ ? tool.name.slice(prefix.length)
369
+ : tool.name;
370
+ }
371
+ const cmd = targetProgram.command(cmdName).description(tool.description);
229
372
  // Register aliases
230
373
  for (const alias of tool.cli.aliases) {
231
374
  cmd.alias(alias);
@@ -236,7 +379,7 @@ function registerToolCommand(parentProgram, tool, clientFactory) {
236
379
  if (tool.cli.primary_arg && props[tool.cli.primary_arg]) {
237
380
  const param = props[tool.cli.primary_arg];
238
381
  const isRequired = required.includes(tool.cli.primary_arg);
239
- const bracket = isRequired ? "<value>" : "[value]";
382
+ const bracket = isRequired ? '<value>' : '[value]';
240
383
  cmd.argument(bracket, param.description || tool.cli.primary_arg);
241
384
  }
242
385
  // Add remaining params as --options
@@ -247,8 +390,8 @@ function registerToolCommand(parentProgram, tool, clientFactory) {
247
390
  const isRequired = required.includes(name);
248
391
  const paramType = Array.isArray(param.type) ? param.type[0] : param.type;
249
392
  const shortAlias = aliases[name];
250
- const shortPrefix = shortAlias ? `-${shortAlias}, ` : "";
251
- if (paramType === "array") {
393
+ const shortPrefix = shortAlias ? `-${shortAlias}, ` : '';
394
+ if (paramType === 'array') {
252
395
  const flag = `${shortPrefix}--${name} <values...>`;
253
396
  if (isRequired) {
254
397
  cmd.requiredOption(flag, param.description || name);
@@ -257,7 +400,7 @@ function registerToolCommand(parentProgram, tool, clientFactory) {
257
400
  cmd.option(flag, param.description || name);
258
401
  }
259
402
  }
260
- else if (paramType === "boolean") {
403
+ else if (paramType === 'boolean') {
261
404
  cmd.option(`${shortPrefix}--${name}`, param.description || name);
262
405
  }
263
406
  else {
@@ -273,13 +416,18 @@ function registerToolCommand(parentProgram, tool, clientFactory) {
273
416
  // Add -o/--output-file option for tools that produce downloadable files
274
417
  const outputFileKeys = tool.cli.output_file_keys || [];
275
418
  if (outputFileKeys.length > 0) {
276
- cmd.option("-o, --output-file <path>", "Download the generated file to a local path");
419
+ cmd.option('-o, --output-file <path>', 'Download the generated file to a local path');
420
+ }
421
+ // For the aidrive tool, add client-side options used with --local_file upload
422
+ if (tool.name === 'aidrive') {
423
+ cmd.option('--local_file <path>', 'Local file path to upload to AI Drive (supports files >5 MB; alternative to --file_content)');
424
+ cmd.option('--override', 'Overwrite an existing file at the destination path (used with --local_file)', false);
277
425
  }
278
426
  // Generic action handler
279
427
  cmd.action(async (primaryArgValue, opts) => {
280
428
  // Commander passes opts as second arg when there's an argument,
281
429
  // or as first arg when there's no argument
282
- if (typeof primaryArgValue === "object" && primaryArgValue !== null) {
430
+ if (typeof primaryArgValue === 'object' && primaryArgValue !== null) {
283
431
  opts = primaryArgValue;
284
432
  primaryArgValue = undefined;
285
433
  }
@@ -299,30 +447,62 @@ function registerToolCommand(parentProgram, tool, clientFactory) {
299
447
  const paramType = Array.isArray(param.type)
300
448
  ? param.type[0]
301
449
  : param.type;
302
- if (paramType === "integer" || paramType === "number") {
450
+ if (paramType === 'integer' || paramType === 'number') {
303
451
  const num = Number(args[name]);
304
452
  if (!isNaN(num)) {
305
- args[name] = paramType === "integer" ? Math.floor(num) : num;
453
+ args[name] = paramType === 'integer' ? Math.floor(num) : num;
306
454
  }
307
455
  }
308
456
  }
309
457
  }
458
+ // aidrive: strip client-side-only options before sending to API.
459
+ // --local_file and --override are only meaningful to the CLI and must
460
+ // never be forwarded to the server (other aidrive actions like ls,
461
+ // mkdir, move don't know about them).
462
+ if (tool.name === 'aidrive') {
463
+ delete args.local_file;
464
+ delete args.override;
465
+ }
466
+ // aidrive: handle --local_file for the upload action.
467
+ // Stream the file directly to AI Drive via the dedicated tool_cli
468
+ // endpoint (POST /api/tool_cli/aidrive/upload) — no blob middleman.
469
+ if (tool.name === 'aidrive' &&
470
+ primaryArgValue === 'upload' &&
471
+ opts.local_file) {
472
+ const localFilePath = opts.local_file;
473
+ const override = !!opts.override;
474
+ if (!fs.existsSync(localFilePath)) {
475
+ logError(`File not found: ${localFilePath}`);
476
+ process.exit(1);
477
+ }
478
+ // args.upload_path is safe here: args = { ...opts } at line above, and
479
+ // only local_file/override were deleted from args — upload_path is intact.
480
+ const rawUploadPath = args.upload_path ||
481
+ `/${pathModule.basename(localFilePath)}`;
482
+ const uploadPath = rawUploadPath.startsWith('/')
483
+ ? rawUploadPath
484
+ : `/${rawUploadPath}`;
485
+ // Stream directly to AI Drive — single request, no blob middleman
486
+ const result = await uploadToAiDrive(localFilePath, uploadPath, override);
487
+ output(result);
488
+ return;
489
+ }
310
490
  // Resolve local file paths to uploaded URLs
311
491
  const resolvedArgs = await resolveLocalFiles(args, client);
312
492
  const result = await client.executeTool(tool.name, resolvedArgs);
313
493
  // If -o was specified and tool succeeded, download the output file
314
494
  if (outputFilePath &&
315
495
  outputFileKeys.length > 0 &&
316
- result.status === "ok" &&
496
+ result.status === 'ok' &&
317
497
  result.data &&
318
- typeof result.data === "object") {
498
+ typeof result.data === 'object') {
319
499
  const fileUrl = extractFileUrl(result.data, outputFileKeys);
320
500
  if (fileUrl) {
321
501
  const localPath = await downloadToFile(fileUrl, outputFilePath, client);
322
502
  result.data.local_path = localPath;
323
503
  }
324
504
  else {
325
- info("Warning: Could not find a file URL in the result to download");
505
+ info('Warning: Could not find a file URL in the result to download');
326
506
  }
327
507
  }
328
508
  output(result);
@@ -338,9 +518,9 @@ function registerToolCommand(parentProgram, tool, clientFactory) {
338
518
  // ============================================
339
519
  // List tools
340
520
  program
341
- .command("list-tools")
342
- .alias("ls")
343
- .description("List all available tools")
521
+ .command('list-tools')
522
+ .alias('ls')
523
+ .description('List all available tools')
344
524
  .action(async () => {
345
525
  try {
346
526
  const client = createClient();
@@ -354,9 +534,9 @@ program
354
534
  });
355
535
  // Upload file - get upload URL, upload file, return file wrapper URL
356
536
  program
357
- .command("upload")
358
- .description("Upload a local file and get a file wrapper URL")
359
- .argument("<file>", "Local file path to upload")
537
+ .command('upload')
538
+ .description('Upload a local file and get a file wrapper URL')
539
+ .argument('<file>', 'Local file path to upload')
360
540
  .action(async (filePath) => {
361
541
  try {
362
542
  if (!fs.existsSync(filePath)) {
@@ -367,11 +547,11 @@ program
367
547
  const file_wrapper_url = await uploadLocalFile(filePath, client);
368
548
  const fileName = pathModule.basename(filePath);
369
549
  const ext = pathModule.extname(filePath).toLowerCase();
370
- const contentType = CONTENT_TYPE_MAP[ext] || "application/octet-stream";
550
+ const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
371
551
  const stats = fs.statSync(filePath);
372
552
  output({
373
- status: "ok",
374
- message: "File uploaded successfully",
553
+ status: 'ok',
554
+ message: 'File uploaded successfully',
375
555
  data: {
376
556
  file_wrapper_url,
377
557
  file_name: fileName,
@@ -387,17 +567,17 @@ program
387
567
  });
388
568
  // Download file - get download URL from file wrapper URL
389
569
  program
390
- .command("download")
391
- .description("Get a download URL for a file wrapper URL")
392
- .argument("<file_wrapper_url>", "File wrapper URL (e.g., /api/files/s/xxxxx)")
393
- .option("-s, --save <path>", "Download and save to local file path")
570
+ .command('download')
571
+ .description('Get a download URL for a file wrapper URL')
572
+ .argument('<file_wrapper_url>', 'File wrapper URL (e.g., /api/files/s/xxxxx)')
573
+ .option('-s, --save <path>', 'Download and save to local file path')
394
574
  .action(async (fileWrapperUrl, opts) => {
395
575
  try {
396
576
  const client = createClient();
397
577
  const result = await client.getDownloadUrl({
398
578
  file_wrapper_url: fileWrapperUrl,
399
579
  });
400
- if (result.status !== "ok" || !result.data) {
580
+ if (result.status !== 'ok' || !result.data) {
401
581
  logError(`Failed to get download URL: ${result.message}`);
402
582
  process.exit(1);
403
583
  }
@@ -412,8 +592,8 @@ program
412
592
  const localPath = pathModule.resolve(opts.save);
413
593
  info(`Downloaded ${(buffer.length / 1024).toFixed(1)} KB → ${localPath}`);
414
594
  output({
415
- status: "ok",
416
- message: "File downloaded successfully",
595
+ status: 'ok',
596
+ message: 'File downloaded successfully',
417
597
  data: {
418
598
  ...result.data,
419
599
  local_path: localPath,
@@ -433,8 +613,8 @@ program
433
613
  });
434
614
  // Login - browser-based device flow authentication
435
615
  program
436
- .command("login")
437
- .description("Log in to Genspark via browser to obtain an API key")
616
+ .command('login')
617
+ .description('Log in to Genspark via browser to obtain an API key')
438
618
  .action(async () => {
439
619
  const globalOpts = getGlobalOptions();
440
620
  setDebugEnabled(globalOpts.debug);
@@ -442,78 +622,78 @@ program
442
622
  const baseUrl = globalOpts.baseUrl;
443
623
  try {
444
624
  // Step 1: Request device code
445
- info("Requesting device code...");
446
- const deviceResp = await unauthenticatedRequest(baseUrl, "/api/cli_auth/device_code", "POST");
625
+ info('Requesting device code...');
626
+ const deviceResp = await unauthenticatedRequest(baseUrl, '/api/cli_auth/device_code', 'POST');
447
627
  const { device_code, auth_url, poll_interval, expires_in } = deviceResp;
448
628
  // Step 2: Open browser (use spawn to avoid shell command injection)
449
- info("Opening browser for login...");
629
+ info('Opening browser for login...');
450
630
  info(`Login URL: ${auth_url}`);
451
631
  // Validate auth_url is a same-origin HTTP(S) URL before passing to spawn
452
632
  try {
453
633
  const parsed = new URL(auth_url);
454
634
  const base = new URL(baseUrl);
455
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
456
- throw new Error("auth_url is not http(s)");
635
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
636
+ throw new Error('auth_url is not http(s)');
457
637
  }
458
638
  if (parsed.host !== base.host) {
459
- throw new Error("auth_url host does not match baseUrl host");
639
+ throw new Error('auth_url host does not match baseUrl host');
460
640
  }
461
641
  let cmd;
462
642
  let args;
463
- if (process.platform === "win32") {
643
+ if (process.platform === 'win32') {
464
644
  // Avoid cmd.exe which interprets & in URLs as command separators
465
- cmd = "rundll32";
466
- args = ["url.dll,FileProtocolHandler", auth_url];
645
+ cmd = 'rundll32';
646
+ args = ['url.dll,FileProtocolHandler', auth_url];
467
647
  }
468
- else if (process.platform === "darwin") {
469
- cmd = "open";
648
+ else if (process.platform === 'darwin') {
649
+ cmd = 'open';
470
650
  args = [auth_url];
471
651
  }
472
652
  else {
473
- cmd = "xdg-open";
653
+ cmd = 'xdg-open';
474
654
  args = [auth_url];
475
655
  }
476
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
477
- child.on("error", () => {
478
- info("Could not open browser automatically.");
479
- info("Please open the URL above manually.");
656
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
657
+ child.on('error', () => {
658
+ info('Could not open browser automatically.');
659
+ info('Please open the URL above manually.');
480
660
  });
481
661
  child.unref();
482
662
  }
483
663
  catch {
484
- info("Could not open browser automatically.");
485
- info("Please open the URL above manually.");
664
+ info('Could not open browser automatically.');
665
+ info('Please open the URL above manually.');
486
666
  }
487
667
  info(`Waiting for authorization (expires in ${expires_in}s, press Ctrl+C to cancel)...`);
488
668
  // Step 3: Poll for token
489
669
  const startTime = Date.now();
490
670
  const timeoutMs = expires_in * 1000;
491
671
  while (Date.now() - startTime < timeoutMs) {
492
- await new Promise((resolve) => setTimeout(resolve, poll_interval * 1000));
672
+ await new Promise(resolve => setTimeout(resolve, poll_interval * 1000));
493
673
  const elapsedSec = Math.round((Date.now() - startTime) / 1000);
494
674
  const remainingSec = expires_in - elapsedSec;
495
- const tokenResp = await unauthenticatedRequest(baseUrl, `/api/cli_auth/token?code=${encodeURIComponent(device_code)}`, "GET");
496
- if (tokenResp.status === "approved" && tokenResp.api_key) {
675
+ const tokenResp = await unauthenticatedRequest(baseUrl, `/api/cli_auth/token?code=${encodeURIComponent(device_code)}`, 'GET');
676
+ if (tokenResp.status === 'approved' && tokenResp.api_key) {
497
677
  // Save to config
498
678
  const config = loadConfigFile();
499
679
  config.api_key = tokenResp.api_key;
500
680
  saveConfigFile(config);
501
- info("Login successful! API key saved.");
681
+ info('Login successful! API key saved.');
502
682
  output({
503
- status: "ok",
504
- message: "Login successful",
683
+ status: 'ok',
684
+ message: 'Login successful',
505
685
  data: { config_path: getConfigPath() },
506
686
  });
507
687
  return;
508
688
  }
509
- if (tokenResp.status === "expired") {
510
- logError("Authorization expired. Please try again.");
689
+ if (tokenResp.status === 'expired') {
690
+ logError('Authorization expired. Please try again.');
511
691
  process.exit(1);
512
692
  }
513
693
  // status === "pending" — keep polling with countdown
514
694
  info(`Still waiting for authorization... (${remainingSec}s remaining)`);
515
695
  }
516
- logError("Authorization timed out. Please try again.");
696
+ logError('Authorization timed out. Please try again.');
517
697
  process.exit(1);
518
698
  }
519
699
  catch (err) {
@@ -523,8 +703,8 @@ program
523
703
  });
524
704
  // Logout - remove API key from config
525
705
  program
526
- .command("logout")
527
- .description("Log out by removing the saved API key")
706
+ .command('logout')
707
+ .description('Log out by removing the saved API key')
528
708
  .action(() => {
529
709
  const globalOpts = getGlobalOptions();
530
710
  setDebugEnabled(globalOpts.debug);
@@ -532,15 +712,15 @@ program
532
712
  try {
533
713
  const config = loadConfigFile();
534
714
  if (!config.api_key) {
535
- info("Already logged out (no API key in config).");
715
+ info('Already logged out (no API key in config).');
536
716
  return;
537
717
  }
538
718
  delete config.api_key;
539
719
  saveConfigFile(config);
540
- info("Logged out. API key removed.");
720
+ info('Logged out. API key removed.');
541
721
  output({
542
- status: "ok",
543
- message: "Logged out",
722
+ status: 'ok',
723
+ message: 'Logged out',
544
724
  data: { config_path: getConfigPath() },
545
725
  });
546
726
  }
@@ -549,6 +729,160 @@ program
549
729
  process.exit(1);
550
730
  }
551
731
  });
732
+ // Login info - show current user identity
733
+ program
734
+ .command('login-info')
735
+ .alias('me')
736
+ .description('Show current user info (email, plan)')
737
+ .action(async () => {
738
+ try {
739
+ const client = createClient();
740
+ const userInfo = await client.getMe();
741
+ output({
742
+ status: 'ok',
743
+ message: 'success',
744
+ data: userInfo,
745
+ });
746
+ }
747
+ catch (err) {
748
+ logError(err.message);
749
+ process.exit(1);
750
+ }
751
+ });
752
+ // Init OpenCode - generate opencode.json config
753
+ program
754
+ .command('init-opencode')
755
+ .description('Generate an opencode.json config file for OpenCode AI coding tool')
756
+ .option('--model <model>', 'Default model to use (e.g., claude-opus-4-6-1m)')
757
+ .option('-o, --out <path>', 'Output file path', pathModule.resolve('.opencode.json'))
758
+ .action(async (opts) => {
759
+ try {
760
+ const client = createClient();
761
+ const config = await client.getOpencodeConfig(opts.model);
762
+ const outputPath = pathModule.resolve(opts.out);
763
+ fs.writeFileSync(outputPath, JSON.stringify(config, null, 2) + '\n');
764
+ info(`Wrote OpenCode config to ${outputPath}`);
765
+ output({
766
+ status: 'ok',
767
+ message: `Config written to ${outputPath}`,
768
+ data: { path: outputPath },
769
+ });
770
+ }
771
+ catch (err) {
772
+ logError(err.message);
773
+ process.exit(1);
774
+ }
775
+ });
776
+ // ============================================
777
+ // Phone Call Command
778
+ // ============================================
779
+ program
780
+ .command('call')
781
+ .description('Make an AI phone call on your behalf. Validates prerequisites, resolves contact info, and initiates the call.')
782
+ .argument('<recipient>', 'Name of the person or business to call')
783
+ .requiredOption('-c, --contact_info <value>', 'Phone number (e.g. +1-555-123-4567) or Google Maps place_id')
784
+ .option('--is_place_id', 'Treat contact_info as a Google Maps place_id (for businesses)', false)
785
+ .requiredOption('-p, --purpose <value>', "Purpose of the call (e.g. 'Check reservation availability')")
786
+ .option('--dry-run', 'Only validate and resolve contact info, do not initiate the call')
787
+ .action(async (recipient, opts) => {
788
+ try {
789
+ const client = createClient();
790
+ if (opts.dryRun) {
791
+ // Dry run: just call the phone_call tool to validate and resolve
792
+ info('Dry run: validating prerequisites and resolving contact info...');
793
+ const result = await client.executeTool('phone_call', {
794
+ recipient,
795
+ contact_info: opts.contact_info,
796
+ is_place_id: opts.is_place_id,
797
+ purpose: opts.purpose,
798
+ });
799
+ if (result.status === 'ok' && result.session_state) {
800
+ const contacts = result.session_state.local_results;
801
+ if (contacts && contacts.length > 0) {
802
+ const c = contacts[0];
803
+ info('');
804
+ info('=== Contact Resolved ===');
805
+ info(` Name: ${c.title || recipient}`);
806
+ info(` Phone: ${c.phone || '(from place_id)'}`);
807
+ if (c.address)
808
+ info(` Address: ${c.address}`);
809
+ if (c.rating)
810
+ info(` Rating: ${c.rating}`);
811
+ info(` Purpose: ${opts.purpose}`);
812
+ info('');
813
+ info('Dry run complete. Use without --dry-run to initiate the call.');
814
+ }
815
+ }
816
+ output(result);
817
+ return;
818
+ }
819
+ // Full call: use the streaming initiate endpoint
820
+ info(`Initiating AI phone call to "${recipient}"...`);
821
+ info(` Purpose: ${opts.purpose}`);
822
+ info('');
823
+ const result = await client.requestStreaming('/phone_call/initiate', {
824
+ recipient,
825
+ contact_info: opts.contact_info,
826
+ is_place_id: opts.is_place_id,
827
+ purpose: opts.purpose,
828
+ }, (msg) => {
829
+ // Display streaming status updates to stderr
830
+ const step = msg.step || '';
831
+ if (step === 'resolved') {
832
+ const contact = msg.contact;
833
+ if (contact) {
834
+ info('Contact resolved:');
835
+ info(` Name: ${contact.title || recipient}`);
836
+ info(` Phone: ${contact.phone || '(from place_id)'}`);
837
+ if (contact.address)
838
+ info(` Address: ${contact.address}`);
839
+ }
840
+ }
841
+ else if (step === 'calling') {
842
+ info(`Dialing... (project: ${msg.project_id || 'unknown'})`);
843
+ }
844
+ else if (msg.heartbeat) {
845
+ const elapsed = msg.elapsed_seconds;
846
+ const callStep = msg.call_step;
847
+ info(` Call in progress... ${elapsed ? `(${elapsed}s)` : ''}${callStep && callStep !== 'in_progress' ? ` [${callStep}]` : ''}`);
848
+ }
849
+ else if (msg.message) {
850
+ info(` ${msg.message}`);
851
+ }
852
+ });
853
+ // Display final result
854
+ const data = (result.data || {});
855
+ const callStatus = data.call_status || 'unknown';
856
+ const callSummary = data.call_summary || '';
857
+ const isFailure = result.status === 'error' ||
858
+ ['failed', 'canceled', 'busy', 'no-answer'].includes(callStatus);
859
+ info('');
860
+ info(isFailure ? '=== Call Failed ===' : '=== Call Complete ===');
861
+ info(` Recipient: ${data.recipient || recipient}`);
862
+ info(` Status: ${callStatus}`);
863
+ if (data.duration_seconds)
864
+ info(` Duration: ${data.duration_seconds}s`);
865
+ info('');
866
+ info('=== Call Summary ===');
867
+ if (callSummary) {
868
+ info(callSummary);
869
+ }
870
+ else if (isFailure) {
871
+ info(result.message && result.message !== `Phone call completed (${callStatus})`
872
+ ? result.message
873
+ : 'Call failed. The call could not be completed.');
874
+ }
875
+ else {
876
+ info('Call completed. No summary available.');
877
+ }
878
+ info('');
879
+ output(result);
880
+ }
881
+ catch (err) {
882
+ logError(err.message);
883
+ process.exit(1);
884
+ }
885
+ });
552
886
  // ============================================
553
887
  // Dynamic Tool Registration & Startup
554
888
  // ============================================
@@ -568,17 +902,15 @@ async function main() {
568
902
  const baseUrl = globalOpts.baseUrl;
569
903
  const forceRefresh = globalOpts.refresh || false;
570
904
  // Try to load tools from cache
571
- let tools = forceRefresh
572
- ? null
573
- : loadToolsCache(baseUrl);
905
+ let tools = forceRefresh ? null : loadToolsCache(baseUrl);
574
906
  if (tools) {
575
907
  debug(`Loaded ${tools.length} tools from cache`);
576
908
  }
577
909
  else {
578
910
  // Need to fetch from server
579
911
  debug(forceRefresh
580
- ? "Force refreshing tools from server..."
581
- : "No cached tools found, fetching from server...");
912
+ ? 'Force refreshing tools from server...'
913
+ : 'No cached tools found, fetching from server...');
582
914
  // Only fetch if we have an API key
583
915
  if (globalOpts.apiKey) {
584
916
  try {
@@ -594,7 +926,7 @@ async function main() {
594
926
  }
595
927
  }
596
928
  else {
597
- debug("No API key available, skipping tool fetch");
929
+ debug('No API key available, skipping tool fetch');
598
930
  }
599
931
  }
600
932
  // Register dynamic tool commands
@@ -606,7 +938,7 @@ async function main() {
606
938
  // Parse and run
607
939
  program.parse();
608
940
  }
609
- main().catch((err) => {
941
+ main().catch(err => {
610
942
  logError(err.message);
611
943
  process.exit(1);
612
944
  });