@nsketch/cli 0.1.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.
Files changed (3) hide show
  1. package/README.md +49 -0
  2. package/dist/index.js +671 -0
  3. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @nsketch/cli
2
+
3
+ Generate images, videos, voice, motion transfer, and more with [Nsketch AI](https://nsketch.ai) from your terminal — or let your AI agent (Claude Code, Codex, Cursor) do it for you.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @nsketch/cli
9
+ ```
10
+
11
+ ## Sign in
12
+
13
+ ```bash
14
+ nsketch auth login
15
+ ```
16
+
17
+ Opens a browser, takes 5 seconds. Credentials are stored in `~/.config/nsketch/credentials.json`.
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ # What can I use?
23
+ nsketch models list --type image
24
+ nsketch account status
25
+
26
+ # Generate an image and wait for the result URL
27
+ nsketch generate image --prompt "a corgi surfing at golden hour" --wait
28
+
29
+ # Image-to-video (local files are auto-uploaded)
30
+ nsketch generate video --prompt "the corgi rides a wave" --image ./corgi.png --wait
31
+
32
+ # Voice, lipsync, motion transfer, upscaling
33
+ nsketch generate voice --script "Hello world"
34
+ nsketch generate lipsync --video ./clip.mp4 --audio ./voice.mp3
35
+ nsketch generate motion-transfer --image ./character.png --video ./dance.mp4 --wait
36
+ nsketch generate upscale --image ./photo.jpg --mode crystal
37
+
38
+ # Check an async job later
39
+ nsketch status <request-id> --type video --model <model-id>
40
+
41
+ # Upload a file and get a hosted URL
42
+ nsketch upload ./photo.jpg
43
+ ```
44
+
45
+ Global flags: `--json` (machine-readable output on stdout), `--wait`, `--wait-timeout 10m`, `--wait-interval 3s`, `--api-url` (override the API base URL).
46
+
47
+ ## For AI agents
48
+
49
+ All commands support `--json`. Generations consume account credits; check `nsketch account status` first. Async jobs return a request id — prefer `--wait` so the CLI handles polling.
package/dist/index.js ADDED
@@ -0,0 +1,671 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/auth.ts
7
+ import crypto from "crypto";
8
+ import http from "http";
9
+ import open from "open";
10
+
11
+ // src/config.ts
12
+ import fs from "fs";
13
+ import os from "os";
14
+ import path from "path";
15
+ var DEFAULT_API_URL = "https://nsketch.ai";
16
+ var CONFIG_DIR = path.join(os.homedir(), ".config", "nsketch");
17
+ var CREDENTIALS_PATH = path.join(CONFIG_DIR, "credentials.json");
18
+ var CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
19
+ function readJson(file) {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(file, "utf8"));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ function writeJson(file, data, mode) {
27
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
28
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), mode ? { mode } : void 0);
29
+ if (mode) fs.chmodSync(file, mode);
30
+ }
31
+ function loadCredentials() {
32
+ return readJson(CREDENTIALS_PATH);
33
+ }
34
+ function saveCredentials(credentials) {
35
+ writeJson(CREDENTIALS_PATH, credentials, 384);
36
+ }
37
+ function clearCredentials() {
38
+ try {
39
+ fs.unlinkSync(CREDENTIALS_PATH);
40
+ } catch {
41
+ }
42
+ }
43
+ function loadConfig() {
44
+ return readJson(CONFIG_PATH) ?? {};
45
+ }
46
+ function saveConfig(config) {
47
+ writeJson(CONFIG_PATH, config);
48
+ }
49
+ function resolveApiUrl(flagValue) {
50
+ return (flagValue || process.env.NSKETCH_API_URL || loadConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, "");
51
+ }
52
+ function resolveClientId(flagValue) {
53
+ return flagValue || process.env.NSKETCH_OAUTH_CLIENT_ID || loadConfig().clientId || null;
54
+ }
55
+
56
+ // src/auth.ts
57
+ var LOOPBACK_PORTS = [9789, 9790, 9791];
58
+ async function discoverAuthServer(apiUrl2) {
59
+ const resourceRes = await fetch(`${apiUrl2}/.well-known/oauth-protected-resource/mcp`);
60
+ if (!resourceRes.ok) {
61
+ throw new Error(`Failed to discover auth server from ${apiUrl2} (HTTP ${resourceRes.status})`);
62
+ }
63
+ const resource = await resourceRes.json();
64
+ const authServer = resource.authorization_servers?.[0];
65
+ if (!authServer) throw new Error("No authorization server advertised by the API");
66
+ const metadataRes = await fetch(
67
+ `${authServer.replace(/\/$/, "")}/.well-known/oauth-authorization-server`
68
+ );
69
+ if (!metadataRes.ok) {
70
+ throw new Error(`Failed to fetch authorization server metadata (HTTP ${metadataRes.status})`);
71
+ }
72
+ const metadata = await metadataRes.json();
73
+ if (!metadata.authorization_endpoint || !metadata.token_endpoint) {
74
+ throw new Error("Authorization server metadata is missing endpoints");
75
+ }
76
+ return metadata;
77
+ }
78
+ function base64url(buffer) {
79
+ return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
80
+ }
81
+ function listenOnFirstFreePort(server) {
82
+ return new Promise((resolve, reject) => {
83
+ const tryPort = (index) => {
84
+ if (index >= LOOPBACK_PORTS.length) {
85
+ reject(new Error(`Ports ${LOOPBACK_PORTS.join(", ")} are all in use`));
86
+ return;
87
+ }
88
+ const port = LOOPBACK_PORTS[index];
89
+ server.once("error", () => tryPort(index + 1));
90
+ server.listen(port, "127.0.0.1", () => {
91
+ server.removeAllListeners("error");
92
+ resolve(port);
93
+ });
94
+ };
95
+ tryPort(0);
96
+ });
97
+ }
98
+ function waitForCallback(server, expectedState) {
99
+ return new Promise((resolve, reject) => {
100
+ server.on("request", (req, res) => {
101
+ const url = new URL(req.url ?? "/", "http://localhost");
102
+ if (url.pathname !== "/callback") {
103
+ res.writeHead(404).end();
104
+ return;
105
+ }
106
+ const code = url.searchParams.get("code");
107
+ const state = url.searchParams.get("state");
108
+ const error = url.searchParams.get("error");
109
+ res.writeHead(200, { "content-type": "text/html" });
110
+ res.end(
111
+ error ? `<h2>Sign-in failed: ${error}</h2><p>You can close this tab.</p>` : "<h2>Signed in to Nsketch.</h2><p>You can close this tab and return to your terminal.</p>"
112
+ );
113
+ if (error) reject(new Error(`Authorization failed: ${error}`));
114
+ else if (state !== expectedState) reject(new Error("State mismatch in OAuth callback"));
115
+ else if (!code) reject(new Error("No authorization code in callback"));
116
+ else resolve(code);
117
+ });
118
+ });
119
+ }
120
+ function toCredentials(tokens, tokenEndpoint, clientId) {
121
+ return {
122
+ accessToken: tokens.access_token,
123
+ refreshToken: tokens.refresh_token,
124
+ expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1e3 : void 0,
125
+ tokenEndpoint,
126
+ clientId
127
+ };
128
+ }
129
+ async function login(opts) {
130
+ const { authorization_endpoint, token_endpoint } = await discoverAuthServer(opts.apiUrl);
131
+ const verifier = base64url(crypto.randomBytes(32));
132
+ const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
133
+ const state = base64url(crypto.randomBytes(16));
134
+ const server = http.createServer();
135
+ const port = await listenOnFirstFreePort(server);
136
+ const redirectUri = `http://localhost:${port}/callback`;
137
+ const authorizeUrl = new URL(authorization_endpoint);
138
+ authorizeUrl.searchParams.set("client_id", opts.clientId);
139
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
140
+ authorizeUrl.searchParams.set("response_type", "code");
141
+ authorizeUrl.searchParams.set("scope", "profile email");
142
+ authorizeUrl.searchParams.set("code_challenge", challenge);
143
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
144
+ authorizeUrl.searchParams.set("state", state);
145
+ process.stderr.write(`Opening browser to sign in...
146
+ If it doesn't open, visit:
147
+ ${authorizeUrl}
148
+ `);
149
+ await open(authorizeUrl.toString()).catch(() => {
150
+ });
151
+ try {
152
+ const code = await waitForCallback(server, state);
153
+ const tokenRes = await fetch(token_endpoint, {
154
+ method: "POST",
155
+ headers: { "content-type": "application/x-www-form-urlencoded" },
156
+ body: new URLSearchParams({
157
+ grant_type: "authorization_code",
158
+ code,
159
+ redirect_uri: redirectUri,
160
+ client_id: opts.clientId,
161
+ code_verifier: verifier
162
+ })
163
+ });
164
+ if (!tokenRes.ok) {
165
+ throw new Error(`Token exchange failed (HTTP ${tokenRes.status}): ${await tokenRes.text()}`);
166
+ }
167
+ const credentials = toCredentials(
168
+ await tokenRes.json(),
169
+ token_endpoint,
170
+ opts.clientId
171
+ );
172
+ saveCredentials(credentials);
173
+ const config = loadConfig();
174
+ saveConfig({ ...config, clientId: opts.clientId });
175
+ return credentials;
176
+ } finally {
177
+ server.close();
178
+ }
179
+ }
180
+ async function refreshAccessToken(credentials) {
181
+ if (!credentials.refreshToken) return null;
182
+ const res = await fetch(credentials.tokenEndpoint, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/x-www-form-urlencoded" },
185
+ body: new URLSearchParams({
186
+ grant_type: "refresh_token",
187
+ refresh_token: credentials.refreshToken,
188
+ client_id: credentials.clientId
189
+ })
190
+ });
191
+ if (!res.ok) return null;
192
+ const refreshed = toCredentials(
193
+ await res.json(),
194
+ credentials.tokenEndpoint,
195
+ credentials.clientId
196
+ );
197
+ refreshed.refreshToken ??= credentials.refreshToken;
198
+ saveCredentials(refreshed);
199
+ return refreshed;
200
+ }
201
+ function logout() {
202
+ clearCredentials();
203
+ }
204
+ function currentCredentials() {
205
+ return loadCredentials();
206
+ }
207
+
208
+ // src/api.ts
209
+ var ApiError = class extends Error {
210
+ constructor(status, payload) {
211
+ super(typeof payload.error === "string" ? payload.error : `HTTP ${status}`);
212
+ this.status = status;
213
+ this.payload = payload;
214
+ }
215
+ };
216
+ async function rawRequest(apiUrl2, credentials, method, path3, opts = {}) {
217
+ const url = new URL(path3, apiUrl2);
218
+ for (const [key, value] of Object.entries(opts.query ?? {})) {
219
+ if (value !== void 0) url.searchParams.set(key, value);
220
+ }
221
+ const res = await fetch(url, {
222
+ method,
223
+ headers: {
224
+ ...credentials ? { authorization: `Bearer ${credentials.accessToken}` } : {},
225
+ ...opts.body !== void 0 ? { "content-type": "application/json" } : {}
226
+ },
227
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
228
+ });
229
+ const json = await res.json().catch(() => ({})) ?? {};
230
+ return { status: res.status, json };
231
+ }
232
+ async function apiRequest(apiUrl2, method, path3, opts = {}) {
233
+ let credentials = currentCredentials();
234
+ if (!credentials) {
235
+ if (opts.requireAuth === false) {
236
+ const result2 = await rawRequest(apiUrl2, null, method, path3, opts);
237
+ if (result2.status >= 400) throw new ApiError(result2.status, result2.json);
238
+ return result2;
239
+ }
240
+ throw new Error("Not authenticated. Run: nsketch auth login");
241
+ }
242
+ if (credentials.expiresAt && credentials.expiresAt < Date.now() + 3e4) {
243
+ credentials = await refreshAccessToken(credentials) ?? credentials;
244
+ }
245
+ let result = await rawRequest(apiUrl2, credentials, method, path3, opts);
246
+ if (result.status === 401) {
247
+ const refreshed = await refreshAccessToken(credentials);
248
+ if (refreshed) {
249
+ result = await rawRequest(apiUrl2, refreshed, method, path3, opts);
250
+ }
251
+ }
252
+ if (result.status === 401) {
253
+ throw new Error("Session expired. Run: nsketch auth login");
254
+ }
255
+ if (result.status >= 400) {
256
+ throw new ApiError(result.status, result.json);
257
+ }
258
+ return result;
259
+ }
260
+
261
+ // src/output.ts
262
+ var jsonMode = false;
263
+ function setJsonMode(enabled) {
264
+ jsonMode = enabled;
265
+ }
266
+ function info(message) {
267
+ process.stderr.write(`${message}
268
+ `);
269
+ }
270
+ function emit(data, humanText) {
271
+ if (jsonMode) {
272
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
273
+ `);
274
+ } else {
275
+ process.stdout.write(`${humanText ?? JSON.stringify(data, null, 2)}
276
+ `);
277
+ }
278
+ }
279
+ function fail(message, data) {
280
+ if (jsonMode) {
281
+ process.stdout.write(`${JSON.stringify({ error: message, ...data ? { details: data } : {} })}
282
+ `);
283
+ } else {
284
+ process.stderr.write(`Error: ${message}
285
+ `);
286
+ if (data) process.stderr.write(`${JSON.stringify(data, null, 2)}
287
+ `);
288
+ }
289
+ process.exit(1);
290
+ }
291
+
292
+ // src/media.ts
293
+ import fs2 from "fs";
294
+ import path2 from "path";
295
+ var CONTENT_TYPES = {
296
+ ".jpg": "image/jpeg",
297
+ ".jpeg": "image/jpeg",
298
+ ".png": "image/png",
299
+ ".webp": "image/webp",
300
+ ".gif": "image/gif",
301
+ ".mp4": "video/mp4",
302
+ ".mov": "video/quicktime",
303
+ ".webm": "video/webm",
304
+ ".mp3": "audio/mpeg",
305
+ ".wav": "audio/wav",
306
+ ".m4a": "audio/mp4",
307
+ ".ogg": "audio/ogg"
308
+ };
309
+ async function uploadFile(apiUrl2, filePath) {
310
+ const absolute = path2.resolve(filePath);
311
+ const bytes = fs2.readFileSync(absolute);
312
+ const extension = path2.extname(absolute).toLowerCase();
313
+ const contentType = CONTENT_TYPES[extension] ?? "application/octet-stream";
314
+ const presign = await apiRequest(apiUrl2, "POST", "/api/mobile/r2-presign", {
315
+ body: {
316
+ contentType,
317
+ fileName: path2.basename(absolute),
318
+ fileSize: bytes.byteLength
319
+ }
320
+ });
321
+ const uploadUrl = presign.json.uploadUrl;
322
+ const cacheControl = presign.json.cacheControl;
323
+ const put = await fetch(uploadUrl, {
324
+ method: "PUT",
325
+ headers: {
326
+ "content-type": contentType,
327
+ ...cacheControl ? { "cache-control": cacheControl } : {}
328
+ },
329
+ body: bytes
330
+ });
331
+ if (!put.ok) {
332
+ throw new Error(`Upload failed with HTTP ${put.status}`);
333
+ }
334
+ return presign.json.url;
335
+ }
336
+ async function resolveMedia(apiUrl2, value) {
337
+ if (!value) return void 0;
338
+ if (/^https?:\/\//.test(value)) return value;
339
+ if (!fs2.existsSync(value)) {
340
+ throw new Error(`Media not found (not a URL and not a local file): ${value}`);
341
+ }
342
+ info(`Uploading ${value}...`);
343
+ return uploadFile(apiUrl2, value);
344
+ }
345
+
346
+ // src/poll.ts
347
+ function parseDuration(value) {
348
+ const match = /^(\d+)(ms|s|m|h)?$/.exec(value.trim());
349
+ if (!match) throw new Error(`Invalid duration: ${value} (use e.g. "90s", "10m")`);
350
+ const amount = Number(match[1]);
351
+ const unit = match[2] ?? "s";
352
+ return amount * { ms: 1, s: 1e3, m: 6e4, h: 36e5 }[unit];
353
+ }
354
+ async function fetchStatus(apiUrl2, target) {
355
+ if (target.type === "image") {
356
+ return apiRequest(apiUrl2, "GET", "/api/mobile/generate-images/status", {
357
+ query: { requestId: target.requestId, modelId: target.model }
358
+ });
359
+ }
360
+ if (target.type === "video") {
361
+ return apiRequest(apiUrl2, "GET", "/api/mobile/generate-video/status", {
362
+ query: {
363
+ requestId: target.requestId,
364
+ modelId: target.model,
365
+ endpoint: target.endpoint
366
+ }
367
+ });
368
+ }
369
+ return apiRequest(apiUrl2, "GET", "/api/mobile/generation-status", {
370
+ query: { requestId: target.requestId }
371
+ });
372
+ }
373
+ async function waitForCompletion(apiUrl2, target, opts) {
374
+ const deadline = Date.now() + opts.timeoutMs;
375
+ let dots = 0;
376
+ for (; ; ) {
377
+ const result = await fetchStatus(apiUrl2, target);
378
+ if (result.status !== 202) return result;
379
+ if (Date.now() + opts.intervalMs > deadline) {
380
+ throw new Error(
381
+ `Timed out waiting for ${target.requestId}. Check later with: nsketch status ${target.requestId} --type ${target.type}`
382
+ );
383
+ }
384
+ if (process.stderr.isTTY) {
385
+ dots = (dots + 1) % 4;
386
+ process.stderr.write(`\rGenerating${".".repeat(dots)} `);
387
+ }
388
+ await new Promise((resolve) => setTimeout(resolve, opts.intervalMs));
389
+ }
390
+ }
391
+
392
+ // src/index.ts
393
+ var program = new Command();
394
+ program.name("nsketch").description("Nsketch AI \u2014 generate images, videos, voice, and more from your terminal.").version("0.1.0").option("--json", "machine-readable JSON output on stdout").option("--api-url <url>", "API base URL (default: https://nsketch.ai)").hook("preAction", (thisCommand) => {
395
+ setJsonMode(Boolean(thisCommand.opts().json));
396
+ });
397
+ var apiUrl = () => resolveApiUrl(program.opts().apiUrl);
398
+ function addWaitOptions(command) {
399
+ return command.option("--wait", "block until the generation finishes and print the result").option("--wait-timeout <duration>", 'max time to wait, e.g. "10m"', "10m").option("--wait-interval <duration>", 'poll interval, e.g. "3s"', "3s");
400
+ }
401
+ async function finishGeneration(submitJson, target, opts) {
402
+ if (!opts.wait) {
403
+ emit(
404
+ submitJson,
405
+ `Submitted. Request id: ${target.requestId}
406
+ Check with: nsketch status ${target.requestId} --type ${target.type}${"model" in target && target.model ? ` --model ${target.model}` : ""}${"endpoint" in target && target.endpoint ? ` --endpoint ${target.endpoint}` : ""}`
407
+ );
408
+ return;
409
+ }
410
+ const result = await waitForCompletion(apiUrl(), target, {
411
+ timeoutMs: parseDuration(opts.waitTimeout),
412
+ intervalMs: parseDuration(opts.waitInterval)
413
+ });
414
+ if (process.stderr.isTTY) process.stderr.write("\r");
415
+ const urls = extractResultUrls(result.json);
416
+ emit(
417
+ { ...result.json, requestId: target.requestId },
418
+ urls.length ? urls.join("\n") : JSON.stringify(result.json, null, 2)
419
+ );
420
+ }
421
+ function extractResultUrls(json) {
422
+ const urls = [];
423
+ if (Array.isArray(json.images)) {
424
+ for (const image of json.images) {
425
+ if (typeof image === "string") urls.push(image);
426
+ else if (image && typeof image.url === "string")
427
+ urls.push(image.url);
428
+ }
429
+ }
430
+ if (Array.isArray(json.videos)) {
431
+ for (const video of json.videos) {
432
+ if (typeof video === "string") urls.push(video);
433
+ else if (video && typeof video.url === "string")
434
+ urls.push(video.url);
435
+ }
436
+ }
437
+ for (const key of ["videoUrl", "audioUrl", "url", "resultVideoUrl"]) {
438
+ if (typeof json[key] === "string") urls.push(json[key]);
439
+ }
440
+ return urls;
441
+ }
442
+ var auth = program.command("auth").description("Authentication");
443
+ auth.command("login").description("Sign in with your Nsketch account (opens a browser)").option("--client-id <id>", "OAuth client id (or set NSKETCH_OAUTH_CLIENT_ID)").action(async (opts) => {
444
+ const clientId = resolveClientId(opts.clientId);
445
+ if (!clientId) {
446
+ fail(
447
+ 'Missing OAuth client id. Pass --client-id or set NSKETCH_OAUTH_CLIENT_ID (from the "Nsketch CLI" OAuth app in Clerk).'
448
+ );
449
+ }
450
+ await login({ apiUrl: apiUrl(), clientId });
451
+ const me = await apiRequest(apiUrl(), "GET", "/api/mobile/me");
452
+ emit(me.json, `Signed in as ${me.json.email} \u2014 ${me.json.plan} plan, ${me.json.totalCredits} credits`);
453
+ });
454
+ auth.command("logout").description("Remove saved credentials").action(() => {
455
+ logout();
456
+ emit({ success: true }, "Logged out.");
457
+ });
458
+ auth.command("status").description("Show authentication status").action(async () => {
459
+ if (!currentCredentials()) {
460
+ fail("Not authenticated. Run: nsketch auth login");
461
+ }
462
+ const me = await apiRequest(apiUrl(), "GET", "/api/mobile/me");
463
+ emit(me.json, `Signed in as ${me.json.email} \u2014 ${me.json.plan} plan, ${me.json.totalCredits} credits`);
464
+ });
465
+ program.command("account").description("Account info").command("status").description("Show credits and plan").action(async () => {
466
+ const me = await apiRequest(apiUrl(), "GET", "/api/mobile/me");
467
+ emit(
468
+ me.json,
469
+ `${me.json.email} \u2014 ${me.json.plan} plan
470
+ Credits: ${me.json.credits} plan + ${me.json.additionalCredits} additional = ${me.json.totalCredits}`
471
+ );
472
+ });
473
+ var models = program.command("models").description("Model catalog");
474
+ models.command("list").description("List available models").option("--type <type>", "image | video | voice").action(async (opts) => {
475
+ const result = await apiRequest(apiUrl(), "GET", "/api/mobile/models", {
476
+ query: { type: opts.type },
477
+ requireAuth: false
478
+ });
479
+ const list = result.json.models ?? [];
480
+ emit(result.json, list.map((m) => `${m.id.padEnd(36)} ${m.type.padEnd(6)} ${m.label}`).join("\n"));
481
+ });
482
+ var generate = program.command("generate").description("Create generations");
483
+ addWaitOptions(
484
+ generate.command("image").description("Generate images from a prompt").requiredOption("--prompt <text>", "text prompt").option("--model <id>", "model id (see: nsketch models list --type image)").option("--aspect-ratio <ratio>", "e.g. 1:1, 16:9, 9:16").option("--num <count>", "number of images (1-4)").option("--image <pathOrUrl...>", "reference images for edit models (local path or URL)")
485
+ ).action(
486
+ async (opts) => {
487
+ const imageUrls = opts.image ? await Promise.all(opts.image.map((value) => resolveMedia(apiUrl(), value))) : void 0;
488
+ const result = await apiRequest(apiUrl(), "POST", "/api/mobile/generate-images", {
489
+ body: {
490
+ prompt: opts.prompt,
491
+ model: opts.model,
492
+ aspectRatio: opts.aspectRatio,
493
+ numImages: opts.num ? Number(opts.num) : void 0,
494
+ attachmentImages: imageUrls?.map((url) => ({ url }))
495
+ }
496
+ });
497
+ if (result.json.images) {
498
+ emit(result.json, extractResultUrls(result.json).join("\n"));
499
+ return;
500
+ }
501
+ await finishGeneration(
502
+ result.json,
503
+ {
504
+ type: "image",
505
+ requestId: String(result.json.requestId),
506
+ model: String(result.json.modelId ?? opts.model ?? "")
507
+ },
508
+ opts
509
+ );
510
+ }
511
+ );
512
+ addWaitOptions(
513
+ generate.command("video").description("Generate a video from a prompt (optionally image-to-video)").requiredOption("--prompt <text>", "text prompt").option("--model <id>", "model id (see: nsketch models list --type video)").option("--image <pathOrUrl>", "source image for image-to-video").option("--start-frame <pathOrUrl>").option("--end-frame <pathOrUrl>").option("--duration <seconds>").option("--resolution <res>", "e.g. 720p, 1080p").option("--aspect-ratio <ratio>").option("--audio", "generate audio where supported")
514
+ ).action(
515
+ async (opts) => {
516
+ const result = await apiRequest(apiUrl(), "POST", "/api/mobile/generate-video", {
517
+ body: {
518
+ prompt: opts.prompt,
519
+ model: opts.model,
520
+ imageUrl: await resolveMedia(apiUrl(), opts.image),
521
+ startFrameImageUrl: await resolveMedia(apiUrl(), opts.startFrame),
522
+ endFrameImageUrl: await resolveMedia(apiUrl(), opts.endFrame),
523
+ duration: opts.duration ? Number(opts.duration) : void 0,
524
+ resolution: opts.resolution,
525
+ aspectRatio: opts.aspectRatio,
526
+ generateAudio: opts.audio
527
+ }
528
+ });
529
+ await finishGeneration(
530
+ result.json,
531
+ {
532
+ type: "video",
533
+ requestId: String(result.json.requestId ?? result.json.taskId),
534
+ model: String(result.json.modelId ?? opts.model ?? ""),
535
+ endpoint: typeof result.json.endpoint === "string" ? result.json.endpoint : void 0
536
+ },
537
+ opts
538
+ );
539
+ }
540
+ );
541
+ generate.command("voice").description("Generate speech from a script (text-to-speech)").requiredOption("--script <text>").option("--voice <preset>", "preset voice name").option("--voice-id <id>", "cloned voice id").option("--model <id>", "voice model id").option("--language <lang>").action(
542
+ async (opts) => {
543
+ const result = await apiRequest(apiUrl(), "POST", "/api/generate-voice", {
544
+ body: {
545
+ script: opts.script,
546
+ voicePreset: opts.voice,
547
+ voiceId: opts.voiceId,
548
+ modelId: opts.model,
549
+ language: opts.language
550
+ }
551
+ });
552
+ emit(result.json, extractResultUrls(result.json).join("\n") || JSON.stringify(result.json, null, 2));
553
+ }
554
+ );
555
+ generate.command("clone-voice").description("Clone a voice from an audio sample").requiredOption("--audio <pathOrUrl>").requiredOption("--name <name>").option("--gender <gender>").option("--language <lang>", "source language").action(async (opts) => {
556
+ const result = await apiRequest(apiUrl(), "POST", "/api/clone-voice-sonic", {
557
+ body: {
558
+ audioUrl: await resolveMedia(apiUrl(), opts.audio),
559
+ name: opts.name,
560
+ gender: opts.gender,
561
+ sourceLanguage: opts.language
562
+ }
563
+ });
564
+ emit(result.json);
565
+ });
566
+ addWaitOptions(
567
+ generate.command("motion-transfer").description("Transfer motion from a reference video onto a character image").requiredOption("--image <pathOrUrl>", "character image").requiredOption("--video <pathOrUrl>", "reference video").option("--prompt <text>").option("--mode <mode>", "standard | pro").option("--aspect-ratio <ratio>", "default 9:16")
568
+ ).action(
569
+ async (opts) => {
570
+ const result = await apiRequest(apiUrl(), "POST", "/api/mobile/motion-transfer", {
571
+ body: {
572
+ imageUrl: await resolveMedia(apiUrl(), opts.image),
573
+ videoUrl: await resolveMedia(apiUrl(), opts.video),
574
+ prompt: opts.prompt,
575
+ mode: opts.mode,
576
+ aspectRatio: opts.aspectRatio
577
+ }
578
+ });
579
+ await finishGeneration(
580
+ result.json,
581
+ { type: "generic", requestId: String(result.json.taskId) },
582
+ opts
583
+ );
584
+ }
585
+ );
586
+ generate.command("lipsync").description("Lipsync a video (or animate a photo) to audio").requiredOption("--audio <pathOrUrl>").option("--video <pathOrUrl>", "video to lipsync").option("--image <pathOrUrl>", "photo to animate (talking photo)").option("--mode <mode>", "standard | pro").action(
587
+ async (opts) => {
588
+ if (!opts.video && !opts.image) fail("Provide --video or --image");
589
+ const audioUrl = await resolveMedia(apiUrl(), opts.audio);
590
+ const result = opts.video ? await apiRequest(apiUrl(), "POST", "/api/video-lipsync", {
591
+ body: {
592
+ videoUrl: await resolveMedia(apiUrl(), opts.video),
593
+ audioUrl,
594
+ mode: opts.mode
595
+ }
596
+ }) : await apiRequest(apiUrl(), "POST", "/api/talking-photo", {
597
+ body: {
598
+ imageUrl: await resolveMedia(apiUrl(), opts.image),
599
+ audioUrl,
600
+ mode: opts.mode,
601
+ mediaType: "image"
602
+ }
603
+ });
604
+ emit(result.json);
605
+ }
606
+ );
607
+ generate.command("upscale").description("Upscale/enhance an image").requiredOption("--image <pathOrUrl>").option("--mode <mode>", "magic | crystal | 4k", "magic").option("--scale <factor>").option("--prompt <text>", "only used by magic mode").action(
608
+ async (opts) => {
609
+ const imageUrl = await resolveMedia(apiUrl(), opts.image);
610
+ const path3 = opts.mode === "4k" ? "/api/mobile/4k-enhancer" : opts.mode === "crystal" ? "/api/crystal-upscale" : "/api/magic-upscale";
611
+ const result = await apiRequest(apiUrl(), "POST", path3, {
612
+ body: {
613
+ imageUrl,
614
+ scaleFactor: opts.scale ? Number(opts.scale) : void 0,
615
+ ...opts.mode === "magic" ? { prompt: opts.prompt } : {}
616
+ }
617
+ });
618
+ emit(result.json, extractResultUrls(result.json).join("\n") || JSON.stringify(result.json, null, 2));
619
+ }
620
+ );
621
+ generate.command("remove-bg").description("Remove the background from an image").requiredOption("--image <pathOrUrl>").action(async (opts) => {
622
+ const result = await apiRequest(apiUrl(), "POST", "/api/background-remove", {
623
+ body: { imageUrl: await resolveMedia(apiUrl(), opts.image) }
624
+ });
625
+ emit(result.json, extractResultUrls(result.json).join("\n") || JSON.stringify(result.json, null, 2));
626
+ });
627
+ addWaitOptions(
628
+ generate.command("reshoot").description("Place a character (soul) into a source photo").requiredOption("--source <pathOrUrl>", "source photo/scene").option("--soul <id>", "existing soul/character id").option("--reference <pathOrUrl>", "character reference image").option("--aspect-ratio <ratio>").option("--num <count>")
629
+ ).action(
630
+ async (opts) => {
631
+ const result = await apiRequest(apiUrl(), "POST", "/api/mobile/reshoot", {
632
+ body: {
633
+ sourceUrl: await resolveMedia(apiUrl(), opts.source),
634
+ soulId: opts.soul,
635
+ referenceImageUrl: await resolveMedia(apiUrl(), opts.reference),
636
+ aspectRatio: opts.aspectRatio,
637
+ numImages: opts.num ? Number(opts.num) : void 0
638
+ }
639
+ });
640
+ const requestId = result.json.requestId ?? result.json.clientRequestId;
641
+ if (!requestId) {
642
+ emit(result.json);
643
+ return;
644
+ }
645
+ await finishGeneration(result.json, { type: "generic", requestId: String(requestId) }, opts);
646
+ }
647
+ );
648
+ program.command("status <requestId>").description("Check a generation by request id").option("--type <type>", "image | video | generic", "generic").option("--model <id>", "model id from the submit response").option("--endpoint <endpoint>", "endpoint from the video submit response").option("--wait", "block until finished").option("--wait-timeout <duration>", "max time to wait", "10m").option("--wait-interval <duration>", "poll interval", "3s").action(
649
+ async (requestId, opts) => {
650
+ const target = { type: opts.type, requestId, model: opts.model, endpoint: opts.endpoint };
651
+ const result = opts.wait ? await waitForCompletion(apiUrl(), target, {
652
+ timeoutMs: parseDuration(opts.waitTimeout),
653
+ intervalMs: parseDuration(opts.waitInterval)
654
+ }) : await fetchStatus(apiUrl(), target);
655
+ const urls = extractResultUrls(result.json);
656
+ emit(
657
+ { http_status: result.status, ...result.json },
658
+ result.status === 202 ? "Still processing." : urls.join("\n") || JSON.stringify(result.json, null, 2)
659
+ );
660
+ }
661
+ );
662
+ program.command("upload <file>").description("Upload a local file to Nsketch media storage; prints the hosted URL").action(async (file) => {
663
+ const url = await uploadFile(apiUrl(), file);
664
+ emit({ url }, url);
665
+ });
666
+ program.parseAsync().catch((err) => {
667
+ if (err instanceof ApiError) {
668
+ fail(err.message, err.payload);
669
+ }
670
+ fail(err instanceof Error ? err.message : String(err));
671
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@nsketch/cli",
3
+ "version": "0.1.0",
4
+ "description": "Nsketch AI CLI — generate images, videos, voice, and more from your terminal or AI agent.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/Nsketch-AI/cli",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Nsketch-AI/cli.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Nsketch-AI/cli/issues"
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "nsketch": "dist/index.js"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "dev": "tsup --watch",
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "dependencies": {
30
+ "commander": "^14.0.2",
31
+ "open": "^10.2.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.14.0",
35
+ "tsup": "^8.5.0",
36
+ "typescript": "^5.9.2"
37
+ },
38
+ "keywords": [
39
+ "nsketch",
40
+ "ai",
41
+ "image-generation",
42
+ "video-generation",
43
+ "cli"
44
+ ]
45
+ }