@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.
- package/README.md +49 -0
- package/dist/index.js +671 -0
- 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
|
+
}
|