@artillect/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 +58 -0
  2. package/dist/index.js +790 -0
  3. package/package.json +51 -0
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # `@artillect/cli`
2
+
3
+ Fourth Artillect client (Studio / Public API / MCP / CLI). Thin Commander binary over Public API v1. The SDK is **bundled** into this package and is not published separately. **No local model list** — `artillect models` is `GET /api/v1/models`.
4
+
5
+ ```bash
6
+ npm i -g @artillect/cli
7
+ # or, without a global install:
8
+ npx @artillect/cli@latest --help
9
+ ```
10
+
11
+ Requires Node.js **≥ 20**. First publish of a scoped package uses `npm publish --access public` (already set in `publishConfig`).
12
+
13
+ ```bash
14
+ artillect auth login # device flow → ~/.config/artillect/config.json
15
+ # or: export ARTILLECT_API_KEY=art_…
16
+ # export ARTILLECT_BASE_URL=https://dev.artillect.pro # optional
17
+
18
+ artillect balance
19
+ artillect models list
20
+ artillect generate image --prompt "a cat" --wait
21
+ artillect generate video kling-3 --prompt "a cat walks" --start-image ./a.png --wait
22
+ ```
23
+
24
+ `--json` prints the API envelope. `--wait` polls until `status=completed` and downloads media into `--out` (default cwd).
25
+
26
+ ## Updates and hotfixes
27
+
28
+ npm **does not push** new versions to machines that already ran `npm i -g`. After install, the user keeps that exact tarball until they upgrade.
29
+
30
+ | Who | What happens |
31
+ | --------------------- | --------------------------------------------------------------------------------------------------------------- |
32
+ | You ship a CLI hotfix | Bump **patch** (`0.1.0` → `0.1.1`), `pnpm --filter @artillect/cli publish`. |
33
+ | You ship a feature | Bump **minor**. Breaking CLI flags → **major**. |
34
+ | API/server hotfix | Users get it on the next request. No CLI reinstall. Catalog, billing, and generation bugs live on the server. |
35
+ | SDK change | SDK is private and inlined in the CLI bundle. Republish `@artillect/cli` — there is no `@artillect/sdk` on npm. |
36
+
37
+ How a user actually receives a new CLI:
38
+
39
+ ```bash
40
+ npm i -g @artillect/cli@latest
41
+ # or one-shot without touching the global:
42
+ npx @artillect/cli@latest models list
43
+ ```
44
+
45
+ There is no App Store review, no push notification, and no silent auto-update of globals. The CLI prints a **stderr hint at most once a day** when `registry.npmjs.org/@artillect/cli/latest` is newer than the running binary. Disable with `ARTILLECT_NO_UPDATE_CHECK=1`. Announce breaking changes in release notes / Discord / the product changelog — npm will not email end users.
46
+
47
+ `npx @artillect/cli` without `@latest` may use a cached copy. Prefer `@latest` in docs.
48
+
49
+ ## Repo scripts
50
+
51
+ ```bash
52
+ pnpm --filter @artillect/sdk run build
53
+ pnpm --filter @artillect/cli run build
54
+ pnpm --filter @artillect/cli run pack:check # tarball + --help/--version
55
+ pnpm --filter @artillect/cli run smoke # live health + models + device/code
56
+ ```
57
+
58
+ Not in v1: Homebrew, a second catalog, `@artillect/mcp` on npm (MCP is the hosted URL).
package/dist/index.js ADDED
@@ -0,0 +1,790 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // ../artillect-sdk/dist/uploadMime.js
7
+ import { basename, isAbsolute, resolve } from "node:path";
8
+ import { access, readFile } from "node:fs/promises";
9
+ import { constants as fsConstants } from "node:fs";
10
+ function mimeFromFilename(filename) {
11
+ const ext = String(filename || "").split(".").pop()?.toLowerCase();
12
+ switch (ext) {
13
+ case "jpg":
14
+ case "jpeg":
15
+ return "image/jpeg";
16
+ case "png":
17
+ return "image/png";
18
+ case "webp":
19
+ return "image/webp";
20
+ case "gif":
21
+ return "image/gif";
22
+ case "tif":
23
+ case "tiff":
24
+ return "image/tiff";
25
+ case "mp4":
26
+ return "video/mp4";
27
+ case "webm":
28
+ return "video/webm";
29
+ case "mov":
30
+ return "video/quicktime";
31
+ case "mp3":
32
+ return "audio/mpeg";
33
+ case "wav":
34
+ return "audio/wav";
35
+ case "m4a":
36
+ return "audio/mp4";
37
+ case "ogg":
38
+ return "audio/ogg";
39
+ default:
40
+ return null;
41
+ }
42
+ }
43
+ function mimeFromMagicBytes(bytes) {
44
+ if (!bytes || bytes.length < 4)
45
+ return null;
46
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
47
+ return "image/jpeg";
48
+ if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
49
+ return "image/png";
50
+ }
51
+ if (bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70)
52
+ return "image/gif";
53
+ if (bytes.length >= 12 && bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
54
+ return "image/webp";
55
+ }
56
+ if (bytes[0] === 73 && bytes[1] === 68 && bytes[2] === 51)
57
+ return "audio/mpeg";
58
+ if (bytes[0] === 255 && (bytes[1] & 224) === 224)
59
+ return "audio/mpeg";
60
+ if (bytes.length >= 12 && bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 65 && bytes[10] === 86 && bytes[11] === 69) {
61
+ return "audio/wav";
62
+ }
63
+ if (bytes.length >= 8 && bytes[4] === 102 && bytes[5] === 116 && bytes[6] === 121 && bytes[7] === 112) {
64
+ return "video/mp4";
65
+ }
66
+ if (bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163) {
67
+ return "video/webm";
68
+ }
69
+ return null;
70
+ }
71
+ function resolveUploadContentType(opts) {
72
+ const declared = String(opts.declaredType || "").split(";")[0].trim().toLowerCase();
73
+ if (declared && declared !== "application/octet-stream" && (declared.startsWith("image/") || declared.startsWith("video/") || declared.startsWith("audio/"))) {
74
+ return { contentType: declared, source: "declared" };
75
+ }
76
+ const fromName = mimeFromFilename(opts.filename || "");
77
+ if (fromName)
78
+ return { contentType: fromName, source: "filename" };
79
+ const fromMagic = opts.bytes ? mimeFromMagicBytes(opts.bytes) : null;
80
+ if (fromMagic)
81
+ return { contentType: fromMagic, source: "magic" };
82
+ return { contentType: declared || "application/octet-stream", source: "unknown" };
83
+ }
84
+ async function resolveLocalUploadPath(filePath) {
85
+ const raw = String(filePath || "").trim();
86
+ if (!raw)
87
+ throw new Error("file_path is empty");
88
+ const tried = [];
89
+ const candidates = [];
90
+ if (isAbsolute(raw)) {
91
+ candidates.push(raw);
92
+ } else {
93
+ candidates.push(resolve(process.cwd(), raw));
94
+ const uploadCwd = String(process.env.ARTILLECT_UPLOAD_CWD || "").trim();
95
+ if (uploadCwd)
96
+ candidates.push(resolve(uploadCwd, raw));
97
+ const workspace = String(process.env.CURSOR_WORKSPACE || process.env.PWD || "").trim();
98
+ if (workspace)
99
+ candidates.push(resolve(workspace, raw));
100
+ }
101
+ for (const p of candidates) {
102
+ if (tried.includes(p))
103
+ continue;
104
+ tried.push(p);
105
+ try {
106
+ await access(p, fsConstants.R_OK);
107
+ return { path: p, tried };
108
+ } catch {
109
+ }
110
+ }
111
+ const err = new Error(`ENOENT: file not found. On stdio MCP pass an absolute path; on remote/hosted MCP use file_base64 + filename instead. Tried: ${tried.join(" | ")}`);
112
+ err.code = "ENOENT";
113
+ err.tried = tried;
114
+ throw err;
115
+ }
116
+ async function readLocalUploadFile(filePath) {
117
+ const { path: resolved } = await resolveLocalUploadPath(filePath);
118
+ const buf = await readFile(resolved);
119
+ const bytes = new Uint8Array(buf);
120
+ const filename = basename(resolved);
121
+ const { contentType } = resolveUploadContentType({ filename, bytes });
122
+ return { bytes, filename, contentType };
123
+ }
124
+
125
+ // ../artillect-sdk/dist/http.js
126
+ var JOB_SUFFIX_KIND = [
127
+ ["-api-image", "image"],
128
+ ["-api-video", "video"],
129
+ ["-api-upscale", "upscale"],
130
+ ["-api-switchx", "switchx"],
131
+ ["-api-audio", "audio"],
132
+ ["-api-mesh", "mesh"],
133
+ ["-api-chat", "chat"]
134
+ ];
135
+ function inferJobKind(taskId) {
136
+ const id = taskId.toLowerCase();
137
+ for (const [suffix, kind] of JOB_SUFFIX_KIND) {
138
+ if (id.endsWith(suffix))
139
+ return kind;
140
+ }
141
+ return "unknown";
142
+ }
143
+ function resolvePollPath(taskId, _kind) {
144
+ const inferred = inferJobKind(taskId);
145
+ if (inferred === "unknown") {
146
+ return `/api/v1/generations/${encodeURIComponent(taskId)}`;
147
+ }
148
+ switch (inferred) {
149
+ case "image":
150
+ return `/api/v1/images/generations/${encodeURIComponent(taskId)}`;
151
+ case "video":
152
+ return `/api/v1/videos/generations/${encodeURIComponent(taskId)}`;
153
+ case "upscale":
154
+ return `/api/v1/upscale/generations/${encodeURIComponent(taskId)}`;
155
+ case "switchx":
156
+ return `/api/v1/switchx/generations/${encodeURIComponent(taskId)}`;
157
+ case "audio":
158
+ return `/api/v1/audio/generations/${encodeURIComponent(taskId)}`;
159
+ case "mesh":
160
+ return `/api/v1/mesh/generations/${encodeURIComponent(taskId)}`;
161
+ default:
162
+ return `/api/v1/generations/${encodeURIComponent(taskId)}`;
163
+ }
164
+ }
165
+ function joinUrl(baseUrl, path) {
166
+ const p = path.startsWith("/") ? path : `/${path}`;
167
+ const suffixIndex = p.search(/[?#]/);
168
+ const pathname = suffixIndex === -1 ? p : p.slice(0, suffixIndex);
169
+ const suffix = suffixIndex === -1 ? "" : p.slice(suffixIndex);
170
+ const withSlash = pathname.endsWith("/") ? pathname : `${pathname}/`;
171
+ return `${baseUrl.replace(/\/+$/, "")}${withSlash}${suffix}`;
172
+ }
173
+ async function publicApiFetch(config, method, path, body, extraHeaders) {
174
+ const url = joinUrl(config.baseUrl, path);
175
+ const headers = {
176
+ Authorization: `Bearer ${config.apiKey}`,
177
+ Accept: "application/json"
178
+ };
179
+ let payload;
180
+ if (body !== void 0) {
181
+ headers["Content-Type"] = "application/json";
182
+ payload = JSON.stringify(body);
183
+ }
184
+ Object.assign(headers, extraHeaders);
185
+ const res = await fetch(url, { method, headers, body: payload });
186
+ return parseApiResponse(res);
187
+ }
188
+ async function publicApiUploadFile(config, file) {
189
+ const url = joinUrl(config.baseUrl, "/api/v1/files");
190
+ const { contentType } = resolveUploadContentType({
191
+ declaredType: file.contentType,
192
+ filename: file.filename,
193
+ bytes: file.bytes
194
+ });
195
+ const form = new FormData();
196
+ const blob = new Blob([Buffer.from(file.bytes)], { type: contentType });
197
+ form.append("file", blob, file.filename);
198
+ const res = await fetch(url, {
199
+ method: "POST",
200
+ headers: {
201
+ Authorization: `Bearer ${config.apiKey}`,
202
+ Accept: "application/json"
203
+ },
204
+ body: form
205
+ });
206
+ return parseApiResponse(res);
207
+ }
208
+ function listQueryPath(basePath, params) {
209
+ const search = new URLSearchParams();
210
+ if (params?.limit != null)
211
+ search.set("limit", String(params.limit));
212
+ if (params?.cursor)
213
+ search.set("cursor", params.cursor);
214
+ if (params?.status)
215
+ search.set("status", params.status);
216
+ if (params?.job_id)
217
+ search.set("job_id", params.job_id);
218
+ if (params?.kind)
219
+ search.set("kind", params.kind);
220
+ if (params?.kinds)
221
+ search.set("kinds", params.kinds);
222
+ if (params?.source)
223
+ search.set("source", params.source);
224
+ if (params?.date)
225
+ search.set("date", params.date);
226
+ if (params?.date_from)
227
+ search.set("date_from", params.date_from);
228
+ if (params?.date_to)
229
+ search.set("date_to", params.date_to);
230
+ if (params?.before_created_at)
231
+ search.set("before_created_at", params.before_created_at);
232
+ if (params?.q)
233
+ search.set("q", params.q);
234
+ if (params?.mine_only)
235
+ search.set("mine_only", "1");
236
+ if (params?.actor_user_id != null)
237
+ search.set("actor_user_id", String(params.actor_user_id));
238
+ if (params?.tag_ids)
239
+ search.set("tag_ids", params.tag_ids);
240
+ if (params?.service)
241
+ search.set("service", params.service);
242
+ const qs = search.toString();
243
+ return qs ? `${basePath}?${qs}` : basePath;
244
+ }
245
+ async function parseApiResponse(res) {
246
+ const text = await res.text();
247
+ let parsed = null;
248
+ if (text) {
249
+ try {
250
+ parsed = JSON.parse(text);
251
+ } catch {
252
+ parsed = text;
253
+ }
254
+ }
255
+ if (!res.ok) {
256
+ const obj = parsed && typeof parsed === "object" ? parsed : null;
257
+ const retryHeader = res.headers.get("Retry-After") ?? res.headers.get("retry-after");
258
+ let body = parsed;
259
+ if (res.status === 429 && retryHeader && obj && obj.retry_after_sec == null) {
260
+ const n = Number(retryHeader);
261
+ if (Number.isFinite(n)) {
262
+ body = { ...obj, retry_after_sec: Math.max(1, Math.trunc(n)) };
263
+ }
264
+ }
265
+ return {
266
+ ok: false,
267
+ status: res.status,
268
+ error: String(obj?.error ?? (text || res.statusText)),
269
+ code: obj?.code != null ? String(obj.code) : void 0,
270
+ // Every public-API error envelope carries request_id; dropping it here left
271
+ // the agent-visible message unsearchable in our logs.
272
+ requestId: obj?.request_id != null ? String(obj.request_id) : res.headers.get("X-Request-Id") ?? void 0,
273
+ body
274
+ };
275
+ }
276
+ return { ok: true, status: res.status, body: parsed };
277
+ }
278
+
279
+ // ../artillect-sdk/dist/client.js
280
+ function createClient(opts) {
281
+ const apiKey = String(opts.apiKey || "").trim();
282
+ if (!apiKey)
283
+ throw new Error("apiKey is required");
284
+ const baseUrl = String(opts.baseUrl || "https://app.artillect.pro").trim().replace(/\/+$/, "");
285
+ const config = { apiKey, baseUrl };
286
+ return {
287
+ config,
288
+ getHealth: () => publicApiFetch(config, "GET", "/api/v1/health"),
289
+ getMe: () => publicApiFetch(config, "GET", "/api/v1/me"),
290
+ getBalance: () => publicApiFetch(config, "GET", "/api/v1/balance"),
291
+ listModels: () => publicApiFetch(config, "GET", "/api/v1/models"),
292
+ estimate: (body) => publicApiFetch(config, "POST", "/api/v1/estimate", body),
293
+ uploadFile: (file) => publicApiUploadFile(config, file),
294
+ generateImage: (body) => publicApiFetch(config, "POST", "/api/v1/images/generations", body),
295
+ getImageGeneration: (id) => publicApiFetch(config, "GET", `/api/v1/images/generations/${encodeURIComponent(id)}`),
296
+ listImageGenerations: (params) => publicApiFetch(config, "GET", listQueryPath("/api/v1/images/generations", params)),
297
+ cancelImageGeneration: (id) => publicApiFetch(config, "POST", `/api/v1/images/generations/${encodeURIComponent(id)}/cancel`),
298
+ generateVideo: (body) => publicApiFetch(config, "POST", "/api/v1/videos/generations", body),
299
+ getVideoGeneration: (id) => publicApiFetch(config, "GET", `/api/v1/videos/generations/${encodeURIComponent(id)}`),
300
+ listVideoGenerations: (params) => publicApiFetch(config, "GET", listQueryPath("/api/v1/videos/generations", params)),
301
+ cancelVideoGeneration: (id) => publicApiFetch(config, "POST", `/api/v1/videos/generations/${encodeURIComponent(id)}/cancel`),
302
+ generateUpscale: (body) => publicApiFetch(config, "POST", "/api/v1/upscale/generations", body),
303
+ getUpscaleGeneration: (id) => publicApiFetch(config, "GET", `/api/v1/upscale/generations/${encodeURIComponent(id)}`),
304
+ listUpscaleGenerations: (params) => publicApiFetch(config, "GET", listQueryPath("/api/v1/upscale/generations", params)),
305
+ cancelUpscaleGeneration: (id) => publicApiFetch(config, "POST", `/api/v1/upscale/generations/${encodeURIComponent(id)}/cancel`),
306
+ generateSwitchx: (body) => publicApiFetch(config, "POST", "/api/v1/switchx/generations", body),
307
+ getSwitchxGeneration: (id) => publicApiFetch(config, "GET", `/api/v1/switchx/generations/${encodeURIComponent(id)}`),
308
+ listSwitchxGenerations: (params) => publicApiFetch(config, "GET", listQueryPath("/api/v1/switchx/generations", params)),
309
+ cancelSwitchxGeneration: (id) => publicApiFetch(config, "POST", `/api/v1/switchx/generations/${encodeURIComponent(id)}/cancel`),
310
+ generateMesh: (body) => publicApiFetch(config, "POST", "/api/v1/mesh/generations", body),
311
+ getMeshGeneration: (id) => publicApiFetch(config, "GET", `/api/v1/mesh/generations/${encodeURIComponent(id)}`),
312
+ listMeshGenerations: (params) => publicApiFetch(config, "GET", listQueryPath("/api/v1/mesh/generations", params)),
313
+ cancelMeshGeneration: (id) => publicApiFetch(config, "POST", `/api/v1/mesh/generations/${encodeURIComponent(id)}/cancel`),
314
+ chatCompletion: (body) => publicApiFetch(config, "POST", "/api/v1/chat/completions", body),
315
+ getTask: (id, kind) => publicApiFetch(config, "GET", resolvePollPath(id, kind)),
316
+ getWebhook: () => publicApiFetch(config, "GET", "/api/v1/webhooks"),
317
+ upsertWebhook: (body) => publicApiFetch(config, "POST", "/api/v1/webhooks", body),
318
+ deleteWebhook: () => publicApiFetch(config, "DELETE", "/api/v1/webhooks"),
319
+ rotateWebhookSecret: () => publicApiFetch(config, "POST", "/api/v1/webhooks/rotate-secret"),
320
+ listWebhookDeliveries: (params) => publicApiFetch(config, "GET", listQueryPath("/api/v1/webhooks/deliveries", params)),
321
+ getWebhookDelivery: (eventId) => publicApiFetch(config, "GET", `/api/v1/webhooks/deliveries/${encodeURIComponent(eventId)}`)
322
+ };
323
+ }
324
+
325
+ // src/config.ts
326
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
327
+ import { homedir } from "node:os";
328
+ import { dirname, join } from "node:path";
329
+ var DEFAULT_BASE = "https://app.artillect.pro";
330
+ function configPath() {
331
+ const xdg = String(process.env.XDG_CONFIG_HOME || "").trim();
332
+ if (xdg) return join(xdg, "artillect", "config.json");
333
+ if (process.platform === "win32") {
334
+ const appData = String(process.env.APPDATA || "").trim();
335
+ if (appData) return join(appData, "artillect", "config.json");
336
+ }
337
+ return join(homedir(), ".config", "artillect", "config.json");
338
+ }
339
+ function readFileConfig() {
340
+ try {
341
+ const raw = readFileSync(configPath(), "utf8");
342
+ const parsed = JSON.parse(raw);
343
+ if (!parsed || typeof parsed !== "object") return {};
344
+ return parsed;
345
+ } catch {
346
+ return {};
347
+ }
348
+ }
349
+ function loadCliConfig() {
350
+ const file = readFileConfig();
351
+ const apiKey = String(process.env.ARTILLECT_API_KEY || file.apiKey || "").trim();
352
+ const baseUrl = String(process.env.ARTILLECT_BASE_URL || file.baseUrl || DEFAULT_BASE).trim().replace(/\/+$/, "");
353
+ return { apiKey, baseUrl };
354
+ }
355
+ function requireApiKey() {
356
+ const cfg = loadCliConfig();
357
+ if (!cfg.apiKey) {
358
+ throw new Error("Not logged in. Run `artillect auth login` or set ARTILLECT_API_KEY.");
359
+ }
360
+ return cfg;
361
+ }
362
+ function saveCliConfig(cfg) {
363
+ const path = configPath();
364
+ mkdirSync(dirname(path), { recursive: true });
365
+ writeFileSync(
366
+ path,
367
+ `${JSON.stringify({ apiKey: cfg.apiKey, baseUrl: cfg.baseUrl }, null, 2)}
368
+ `,
369
+ { mode: 384 }
370
+ );
371
+ return path;
372
+ }
373
+ function clearCliConfig() {
374
+ try {
375
+ rmSync(configPath());
376
+ } catch {
377
+ }
378
+ }
379
+
380
+ // src/httpAnon.ts
381
+ async function getJson(baseUrl, path, extraHeaders) {
382
+ const url = joinUrl(baseUrl, path);
383
+ const res = await fetch(url, {
384
+ method: "GET",
385
+ headers: { Accept: "application/json", ...extraHeaders }
386
+ });
387
+ let json = {};
388
+ try {
389
+ json = await res.json();
390
+ } catch {
391
+ json = { error: `HTTP ${res.status}` };
392
+ }
393
+ return { status: res.status, json };
394
+ }
395
+ async function postJson(baseUrl, path, body) {
396
+ const url = joinUrl(baseUrl, path);
397
+ const res = await fetch(url, {
398
+ method: "POST",
399
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
400
+ body: JSON.stringify(body)
401
+ });
402
+ let json = {};
403
+ try {
404
+ json = await res.json();
405
+ } catch {
406
+ json = { error: `HTTP ${res.status}` };
407
+ }
408
+ return { status: res.status, json };
409
+ }
410
+ function sleep(ms) {
411
+ return new Promise((r) => setTimeout(r, ms));
412
+ }
413
+
414
+ // src/auth.ts
415
+ async function authLogin() {
416
+ const { baseUrl } = loadCliConfig();
417
+ const started = await postJson(baseUrl, "/api/v1/device/code", {});
418
+ if (started.status >= 400) {
419
+ throw new Error(String(started.json.error || `device code failed (${started.status})`));
420
+ }
421
+ const deviceCode = String(started.json.device_code || "");
422
+ const userCode = String(started.json.user_code || "");
423
+ const verificationUrl = String(started.json.verification_url || "");
424
+ const intervalSec = Math.max(1, Number(started.json.interval) || 5);
425
+ const expiresIn = Number(started.json.expires_in) || 600;
426
+ if (!deviceCode || !verificationUrl) {
427
+ throw new Error("Device authorization response missing device_code/verification_url");
428
+ }
429
+ console.log("Open this URL in a browser where you are logged into Artillect:");
430
+ console.log(` ${verificationUrl}`);
431
+ if (userCode) console.log(`Confirm code: ${userCode}`);
432
+ console.log("Waiting for approval\u2026");
433
+ const deadline = Date.now() + expiresIn * 1e3;
434
+ while (Date.now() < deadline) {
435
+ await sleep(intervalSec * 1e3);
436
+ const polled = await postJson(baseUrl, "/api/v1/device/token", { device_code: deviceCode });
437
+ const status = String(polled.json.status || "");
438
+ if (status === "pending") continue;
439
+ if (status === "expired" || polled.status === 410) {
440
+ throw new Error("Device code expired. Run `artillect auth login` again.");
441
+ }
442
+ const apiKey = String(polled.json.api_key || "");
443
+ if (status === "completed" && apiKey) {
444
+ const path = saveCliConfig({ apiKey, baseUrl });
445
+ console.log(`Logged in. Key saved to ${path}`);
446
+ return;
447
+ }
448
+ throw new Error(String(polled.json.error || `login failed (${polled.status})`));
449
+ }
450
+ throw new Error("Timed out waiting for browser approval.");
451
+ }
452
+ async function authLogout() {
453
+ clearCliConfig();
454
+ console.log("Logged out.");
455
+ }
456
+ async function authStatus() {
457
+ const envKey = Boolean(String(process.env.ARTILLECT_API_KEY || "").trim());
458
+ try {
459
+ const cfg = requireApiKey();
460
+ const client = createClient({ apiKey: cfg.apiKey, baseUrl: cfg.baseUrl });
461
+ const me = await client.getMe();
462
+ if (!me.ok) {
463
+ console.log(
464
+ `Configured (${envKey ? "ARTILLECT_API_KEY" : configPath()}), but /me failed: ${me.error}`
465
+ );
466
+ process.exitCode = 1;
467
+ return;
468
+ }
469
+ const body = me.body;
470
+ console.log(`Logged in as ${body.email ?? body.user_id ?? "ok"}`);
471
+ console.log(`Base URL: ${cfg.baseUrl}`);
472
+ } catch (err) {
473
+ console.log(err instanceof Error ? err.message : String(err));
474
+ process.exitCode = 1;
475
+ }
476
+ }
477
+
478
+ // src/generate.ts
479
+ import { createWriteStream } from "node:fs";
480
+ import { basename as basename2 } from "node:path";
481
+ import { Readable } from "node:stream";
482
+ import { pipeline } from "node:stream/promises";
483
+
484
+ // src/modelsFormat.ts
485
+ function asRecord(value) {
486
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
487
+ }
488
+ function formatModelsList(body, kind) {
489
+ const groups = [
490
+ ["images", "Images"],
491
+ ["video", "Video"],
492
+ ["chat", "Chat"],
493
+ ["audio", "Audio"],
494
+ ["upscale", "Upscale"],
495
+ ["mesh", "Mesh"],
496
+ ["switchx", "SwitchX"]
497
+ ];
498
+ const lines = [];
499
+ for (const [key, label] of groups) {
500
+ if (kind && key !== kind && !(kind === "image" && key === "images")) continue;
501
+ const group = asRecord(body[key]);
502
+ const models2 = Array.isArray(group.models) ? group.models : [];
503
+ if (!models2.length) continue;
504
+ lines.push(`${label}:`);
505
+ for (const raw of models2) {
506
+ const m = asRecord(raw);
507
+ const slug = String(m.slug || "");
508
+ const name = String(m.display_name || slug);
509
+ lines.push(` ${slug.padEnd(24)} ${name}`);
510
+ }
511
+ lines.push("");
512
+ }
513
+ return lines.join("\n");
514
+ }
515
+
516
+ // src/generate.ts
517
+ function fail(result) {
518
+ if (result.ok) throw new Error("expected error result");
519
+ const extra = result.requestId ? ` (request_id ${result.requestId})` : "";
520
+ throw new Error(`${result.error}${extra}`);
521
+ }
522
+ async function uploadLocal(client, filePath) {
523
+ const file = await readLocalUploadFile(filePath);
524
+ const up = await client.uploadFile({ bytes: file.bytes, filename: file.filename });
525
+ if (!up.ok) fail(up);
526
+ const url = String(asRecord(up.body).url || "").trim();
527
+ if (!url) throw new Error(`Upload of ${basename2(filePath)} returned no url`);
528
+ return url;
529
+ }
530
+ function mediaUrls(body) {
531
+ const images = Array.isArray(body.images) ? body.images : [];
532
+ const videos = Array.isArray(body.videos) ? body.videos : [];
533
+ const fromSlots = [...images, ...videos].map((item) => item && typeof item === "object" ? String(asRecord(item).url || "") : "").filter(Boolean);
534
+ if (fromSlots.length) return fromSlots;
535
+ const media = Array.isArray(body.media_urls) ? body.media_urls : [];
536
+ return media.map((u) => String(u || "")).filter(Boolean);
537
+ }
538
+ function terminalStatus(body) {
539
+ const status = String(body.status || "").toLowerCase();
540
+ if (status === "completed" || status === "failed" || status === "cancelled") {
541
+ return status;
542
+ }
543
+ if (body.done === true) return String(body.error || "") ? "failed" : "completed";
544
+ return null;
545
+ }
546
+ async function waitForTask(client, taskId, jsonMode) {
547
+ const started = Date.now();
548
+ const timeoutMs = 20 * 60 * 1e3;
549
+ while (Date.now() - started < timeoutMs) {
550
+ const poll = await client.getTask(taskId);
551
+ if (!poll.ok) fail(poll);
552
+ const body = asRecord(poll.body);
553
+ const term = terminalStatus(body);
554
+ if (term) {
555
+ if (term !== "completed") {
556
+ throw new Error(String(body.error || `generation ${term}`));
557
+ }
558
+ return body;
559
+ }
560
+ const interval = Math.max(2, Number(body.recommended_poll_interval_sec) || 3);
561
+ if (!jsonMode) {
562
+ const progress = body.progress != null ? ` ${body.progress}%` : "";
563
+ process.stderr.write(`\r${String(body.status || "queued")}${progress} `);
564
+ }
565
+ await sleep(interval * 1e3);
566
+ }
567
+ throw new Error("Timed out waiting for generation");
568
+ }
569
+ async function downloadAll(urls, outDir) {
570
+ const saved = [];
571
+ for (const [i, url] of urls.entries()) {
572
+ const res = await fetch(url);
573
+ if (!res.ok || !res.body) throw new Error(`download failed HTTP ${res.status}`);
574
+ const extGuess = url.includes(".mp4") ? "mp4" : url.includes(".webm") ? "webm" : "png";
575
+ const dest = `${outDir.replace(/\/+$/, "") || "."}/artillect-${Date.now()}-${i + 1}.${extGuess}`;
576
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
577
+ saved.push(dest);
578
+ }
579
+ return saved;
580
+ }
581
+ function printOrJson(jsonMode, payload, lines) {
582
+ if (jsonMode) {
583
+ console.log(JSON.stringify(payload, null, 2));
584
+ return;
585
+ }
586
+ for (const line of lines) console.log(line);
587
+ }
588
+ async function cmdBalance(jsonMode) {
589
+ const cfg = requireApiKey();
590
+ const client = createClient(cfg);
591
+ const res = await client.getBalance();
592
+ if (!res.ok) fail(res);
593
+ const body = asRecord(res.body);
594
+ printOrJson(jsonMode, body, [`Balance: ${body.token_balance ?? 0} tokens`]);
595
+ }
596
+ async function cmdModels(kind, jsonMode) {
597
+ const cfg = loadCliConfig();
598
+ const headers = {};
599
+ if (cfg.apiKey) headers.Authorization = `Bearer ${cfg.apiKey}`;
600
+ const res = await getJson(cfg.baseUrl, "/api/v1/models", headers);
601
+ if (res.status >= 400) {
602
+ throw new Error(String(res.json.error || `GET /api/v1/models failed (${res.status})`));
603
+ }
604
+ if (jsonMode) {
605
+ console.log(JSON.stringify(kind ? res.json[kind] ?? res.json : res.json, null, 2));
606
+ return;
607
+ }
608
+ const text = formatModelsList(res.json, kind);
609
+ if (text) process.stdout.write(text.endsWith("\n") ? text : `${text}
610
+ `);
611
+ }
612
+ async function cmdGenerateImage(opts) {
613
+ const cfg = requireApiKey();
614
+ const client = createClient(cfg);
615
+ const inputUrls = [];
616
+ for (const path of opts.input ?? []) {
617
+ inputUrls.push(await uploadLocal(client, path));
618
+ }
619
+ const res = await client.generateImage({
620
+ prompt: opts.prompt,
621
+ ...opts.model ? { model: opts.model } : {},
622
+ ...inputUrls.length ? { input_urls: inputUrls } : {}
623
+ });
624
+ if (!res.ok) fail(res);
625
+ const body = asRecord(res.body);
626
+ const taskId = String(body.task_id || body.id || "");
627
+ if (!opts.wait) {
628
+ printOrJson(opts.json, body, [`Submitted ${taskId || "(no task_id)"}`]);
629
+ return;
630
+ }
631
+ if (!taskId) throw new Error("submit returned no task_id");
632
+ const done = await waitForTask(client, taskId, opts.json);
633
+ const urls = mediaUrls(done);
634
+ const saved = urls.length ? await downloadAll(urls, opts.out) : [];
635
+ printOrJson(opts.json, { ...done, saved }, [
636
+ `Completed ${taskId}`,
637
+ ...saved.map((p) => `Saved ${p}`),
638
+ ...!saved.length ? urls.map((u) => u) : []
639
+ ]);
640
+ }
641
+ async function cmdGenerateVideo(opts) {
642
+ const cfg = requireApiKey();
643
+ const client = createClient(cfg);
644
+ const body = { model: opts.model, prompt: opts.prompt };
645
+ if (opts.startImage) body.start_image_url = await uploadLocal(client, opts.startImage);
646
+ if (opts.endImage) body.end_image_url = await uploadLocal(client, opts.endImage);
647
+ const res = await client.generateVideo(body);
648
+ if (!res.ok) fail(res);
649
+ const submitted = asRecord(res.body);
650
+ const taskId = String(submitted.task_id || submitted.id || "");
651
+ if (!opts.wait) {
652
+ printOrJson(opts.json, submitted, [`Submitted ${taskId || "(no task_id)"}`]);
653
+ return;
654
+ }
655
+ if (!taskId) throw new Error("submit returned no task_id");
656
+ const done = await waitForTask(client, taskId, opts.json);
657
+ const urls = mediaUrls(done);
658
+ const saved = urls.length ? await downloadAll(urls, opts.out) : [];
659
+ printOrJson(opts.json, { ...done, saved }, [
660
+ `Completed ${taskId}`,
661
+ ...saved.map((p) => `Saved ${p}`),
662
+ ...!saved.length ? urls.map((u) => u) : []
663
+ ]);
664
+ }
665
+
666
+ // src/updateCheck.ts
667
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
668
+ import { dirname as dirname2, join as join2 } from "node:path";
669
+
670
+ // src/version.ts
671
+ function compareSemver(a, b) {
672
+ const pa = parse(a);
673
+ const pb = parse(b);
674
+ if (!pa || !pb) return 0;
675
+ for (let i = 0; i < 3; i++) {
676
+ if (pa[i] > pb[i]) return 1;
677
+ if (pa[i] < pb[i]) return -1;
678
+ }
679
+ return 0;
680
+ }
681
+ function parse(raw) {
682
+ const m = String(raw || "").trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)/);
683
+ if (!m) return null;
684
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
685
+ }
686
+ function cliVersion() {
687
+ return String("0.1.0");
688
+ }
689
+ function updateAvailableMessage(local, latest) {
690
+ return `A new version of @artillect/cli is available (${latest}; you have ${local}).
691
+ Update: npm i -g @artillect/cli@latest`;
692
+ }
693
+
694
+ // src/updateCheck.ts
695
+ var DAY_MS = 24 * 60 * 60 * 1e3;
696
+ function cachePath() {
697
+ return join2(dirname2(configPath()), "update-check.json");
698
+ }
699
+ function shouldSkip() {
700
+ if (process.env.ARTILLECT_NO_UPDATE_CHECK === "1") return true;
701
+ if (process.env.CI === "true" || process.env.CI === "1") return true;
702
+ return false;
703
+ }
704
+ function readLastCheck() {
705
+ try {
706
+ const raw = JSON.parse(readFileSync2(cachePath(), "utf8"));
707
+ return Number(raw.checkedAt) || 0;
708
+ } catch {
709
+ return 0;
710
+ }
711
+ }
712
+ function writeLastCheck() {
713
+ try {
714
+ mkdirSync2(dirname2(cachePath()), { recursive: true });
715
+ writeFileSync2(cachePath(), `${JSON.stringify({ checkedAt: Date.now() })}
716
+ `);
717
+ } catch {
718
+ }
719
+ }
720
+ async function maybeNotifyUpdate() {
721
+ if (shouldSkip()) return;
722
+ if (Date.now() - readLastCheck() < DAY_MS) return;
723
+ writeLastCheck();
724
+ const local = cliVersion();
725
+ try {
726
+ const res = await fetch("https://registry.npmjs.org/@artillect/cli/latest", {
727
+ headers: { Accept: "application/json" },
728
+ signal: AbortSignal.timeout(2500)
729
+ });
730
+ if (!res.ok) return;
731
+ const body = await res.json();
732
+ const latest = String(body.version || "").trim();
733
+ if (!latest || compareSemver(latest, local) <= 0) return;
734
+ process.stderr.write(`${updateAvailableMessage(local, latest)}
735
+ `);
736
+ } catch {
737
+ }
738
+ }
739
+
740
+ // src/index.ts
741
+ var program = new Command();
742
+ program.name("artillect").description(
743
+ "Artillect CLI \u2014 same Public API v1 as Studio, REST, and MCP. Models come from GET /api/v1/models."
744
+ ).version(cliVersion());
745
+ program.hook("preAction", async () => {
746
+ await maybeNotifyUpdate();
747
+ });
748
+ var auth = program.command("auth").description("Device-flow login (or ARTILLECT_API_KEY)");
749
+ auth.command("login").description("Open /connect, poll until an API key is issued").action(authLogin);
750
+ auth.command("logout").description("Remove the saved API key").action(authLogout);
751
+ auth.command("status").description("Show who the current key belongs to").action(authStatus);
752
+ program.command("balance").description("Show token balance").option("--json", "print raw JSON", false).action(async (opts) => {
753
+ await cmdBalance(Boolean(opts.json));
754
+ });
755
+ var models = program.command("models").description("Discovery from GET /api/v1/models (no local catalog)");
756
+ models.command("list").description("List public model slugs").option("-k, --kind <kind>", "images | video | chat | audio | upscale | mesh").option("--json", "print raw JSON", false).action(async (opts) => {
757
+ await cmdModels(opts.kind, Boolean(opts.json));
758
+ });
759
+ var generate = program.command("generate").description("Submit a generation");
760
+ generate.command("image").description("Text-to-image or image edit").requiredOption("-p, --prompt <text>", "prompt").option("-m, --model <slug>", "public image slug (default: gpt-image-2)").option("-i, --input <file...>", "local image files to upload as input_urls").option("-w, --wait", "poll until complete and download results", false).option("-o, --out <dir>", "directory for downloads", ".").option("--json", "print raw JSON", false).action(
761
+ async (opts) => {
762
+ await cmdGenerateImage({
763
+ prompt: opts.prompt,
764
+ model: opts.model,
765
+ input: opts.input,
766
+ wait: Boolean(opts.wait),
767
+ json: Boolean(opts.json),
768
+ out: opts.out
769
+ });
770
+ }
771
+ );
772
+ generate.command("video").description("Text/image-to-video").argument("[model]", "public video slug (default: kling-3-turbo)").requiredOption("-p, --prompt <text>", "prompt").option("-m, --model <slug>", "public video slug (overrides the positional model)").option("--start-image <file>", "local start-frame image").option("--end-image <file>", "local end-frame image").option("-w, --wait", "poll until complete and download results", false).option("-o, --out <dir>", "directory for downloads", ".").option("--json", "print raw JSON", false).action(
773
+ async (positionalModel, opts) => {
774
+ const model = String(opts.model || positionalModel || "kling-3-turbo").trim();
775
+ await cmdGenerateVideo({
776
+ model,
777
+ prompt: opts.prompt,
778
+ startImage: opts.startImage,
779
+ endImage: opts.endImage,
780
+ wait: Boolean(opts.wait),
781
+ json: Boolean(opts.json),
782
+ out: opts.out
783
+ });
784
+ }
785
+ );
786
+ program.parseAsync(process.argv).catch((err) => {
787
+ const message = err instanceof Error ? err.message : String(err);
788
+ console.error(message);
789
+ process.exit(1);
790
+ });
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@artillect/cli",
3
+ "version": "0.1.0",
4
+ "description": "Artillect CLI — generate images and video via Public API v1.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "artillect": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/index.js",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://git.krem.digital/diffusionbooth/artillect.git",
20
+ "directory": "packages/artillect-cli"
21
+ },
22
+ "homepage": "https://app.artillect.pro/docs",
23
+ "bugs": {
24
+ "url": "https://app.artillect.pro/feedback"
25
+ },
26
+ "keywords": [
27
+ "artillect",
28
+ "cli",
29
+ "ai",
30
+ "image-generation",
31
+ "video-generation"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "dependencies": {
37
+ "commander": "^14.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.15.0",
41
+ "esbuild": "^0.25.0",
42
+ "typescript": "^5.8.0",
43
+ "@artillect/sdk": "0.1.0"
44
+ },
45
+ "scripts": {
46
+ "build": "node build.mjs",
47
+ "typecheck": "tsc -p tsconfig.json --noEmit",
48
+ "pack:check": "node scripts/pack-check.mjs",
49
+ "smoke": "node scripts/smoke.mjs"
50
+ }
51
+ }