@flatkey-ai/cli 0.1.6 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flatkey-ai/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Flatkey media generation CLI for image, video, audio, credits, status, and models.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export const DEFAULT_BASE_URL = "https://router.flatkey.ai";
2
2
  export const DEFAULT_MODELS_BASE_URL = "https://console.flatkey.ai";
3
+ export const DEFAULT_CONSOLE_URL = "https://console.flatkey.ai";
3
4
 
4
5
  export class FlatkeyError extends Error {
5
6
  constructor(message, { status } = {}) {
@@ -124,11 +125,17 @@ export function planTextRequest(options) {
124
125
  }
125
126
 
126
127
  export function getCredits(options) {
127
- return requestJson(options, "/v1/credits");
128
+ return requestJson({
129
+ ...options,
130
+ baseUrl: options.baseUrl ?? DEFAULT_CONSOLE_URL,
131
+ }, "/v1/credits");
128
132
  }
129
133
 
130
134
  export function getStatus(options) {
131
- return requestJson(options, "/v1/status");
135
+ return requestJson({
136
+ ...options,
137
+ baseUrl: options.baseUrl ?? DEFAULT_CONSOLE_URL,
138
+ }, "/v1/status");
132
139
  }
133
140
 
134
141
  export function getModels(options) {
@@ -138,6 +145,32 @@ export function getModels(options) {
138
145
  }, "/v1/available_models");
139
146
  }
140
147
 
148
+ export async function createDeviceAuthorization(options) {
149
+ return requestJson({
150
+ ...options,
151
+ baseUrl: options.consoleUrl ?? DEFAULT_CONSOLE_URL,
152
+ }, "/api/cli/device_authorizations", {
153
+ method: "POST",
154
+ body: JSON.stringify({
155
+ client_name: options.clientName ?? "flatkey-cli",
156
+ client_version: options.clientVersion,
157
+ device_id: options.deviceId,
158
+ }),
159
+ });
160
+ }
161
+
162
+ export async function pollDeviceAuthorization(options) {
163
+ return requestJson({
164
+ ...options,
165
+ baseUrl: options.consoleUrl ?? DEFAULT_CONSOLE_URL,
166
+ }, "/api/cli/device_authorizations/token", {
167
+ method: "POST",
168
+ body: JSON.stringify({
169
+ device_code: options.deviceCode,
170
+ }),
171
+ });
172
+ }
173
+
141
174
  async function postJson(options, path, payload) {
142
175
  return requestJsonFromPlan(options, planJsonPost(options, path, payload));
143
176
  }
@@ -257,7 +290,16 @@ async function readJson(response) {
257
290
  }
258
291
 
259
292
  function extractErrorMessage(body, status) {
260
- if (typeof body?.error?.message === "string") return body.error.message;
261
- if (typeof body?.message === "string") return body.message;
293
+ const message = typeof body?.error?.message === "string"
294
+ ? body.error.message
295
+ : typeof body?.message === "string"
296
+ ? body.message
297
+ : undefined;
298
+ if (message === "Token not provided") return missingApiKeyMessage();
299
+ if (message) return message;
262
300
  return `Flatkey API request failed with HTTP ${status}`;
263
301
  }
302
+
303
+ function missingApiKeyMessage() {
304
+ return "Missing Flatkey API key. Create one at https://console.flatkey.ai/keys, then run `flatkey onboard --api-key <key>` or set FLATKEY_API_KEY.";
305
+ }
package/src/artifacts.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
2
3
  import { dirname, extname, join } from "node:path";
3
4
 
4
5
  const DEFAULT_EXTENSIONS = {
@@ -25,6 +26,7 @@ export async function persistArtifacts({
25
26
  output,
26
27
  fetch: fetchImpl = fetch,
27
28
  }) {
29
+ output = expandHomePath(output);
28
30
  const items = extractItems(response);
29
31
  const artifacts = [];
30
32
  await mkdir(output ? dirname(output) : outDir, { recursive: true });
@@ -37,6 +39,13 @@ export async function persistArtifacts({
37
39
  return artifacts;
38
40
  }
39
41
 
42
+ function expandHomePath(path) {
43
+ if (typeof path !== "string") return path;
44
+ if (path === "~") return homedir();
45
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
46
+ return path;
47
+ }
48
+
40
49
  async function persistItem({ kind, item, outDir, output, index, fetchImpl }) {
41
50
  const dataUrl = getString(item, ["url", "data_url", "dataUrl"]);
42
51
  if (dataUrl?.startsWith("data:")) {
package/src/cli.js CHANGED
@@ -3,15 +3,40 @@ const COMMANDS = new Set([
3
3
  "credits",
4
4
  "help",
5
5
  "image",
6
+ "login",
6
7
  "models",
7
8
  "onboard",
9
+ "logout",
10
+ "auth",
8
11
  "status",
9
12
  "text",
10
13
  "version",
11
14
  "video",
12
15
  ]);
13
16
 
14
- const GROUP_ACTIONS = new Set(["audio", "image", "text", "video"]);
17
+ const GROUP_ACTIONS = new Set(["audio", "auth", "image", "text", "video"]);
18
+ const GLOBAL_OPTIONS = new Set(["api_key", "base_url", "dry_run", "help", "json", "output", "out"]);
19
+ const COMMAND_OPTIONS = {
20
+ "audio generate": new Set(["model", "prompt", "similarity_boost", "stability", "style", "voice_id"]),
21
+ "audio music": new Set(["music_length_ms", "prompt"]),
22
+ "audio sfx": new Set(["duration", "prompt"]),
23
+ "audio voices": new Set([]),
24
+ "auth status": new Set([]),
25
+ credits: new Set([]),
26
+ help: new Set(["ai", "command"]),
27
+ image: new Set([]),
28
+ "image generate": new Set(["model", "n", "prompt", "quality", "size"]),
29
+ login: new Set(["console_url", "no_open", "open"]),
30
+ logout: new Set([]),
31
+ models: new Set(["type"]),
32
+ onboard: new Set(["api_key"]),
33
+ status: new Set([]),
34
+ text: new Set([]),
35
+ "text generate": new Set(["model", "prompt"]),
36
+ version: new Set([]),
37
+ video: new Set([]),
38
+ "video generate": new Set(["aspect", "duration", "fps", "model", "prompt", "ratio", "resolution"]),
39
+ };
15
40
 
16
41
  export function parseArgv(argv) {
17
42
  const [group, maybeAction, ...rest] = argv;
@@ -28,11 +53,11 @@ export function parseArgv(argv) {
28
53
  throw new Error(`Unknown command: ${group}`);
29
54
  }
30
55
  if (group === "help" && maybeAction && !maybeAction.startsWith("--")) {
31
- return {
56
+ return validateCommandOptions({
32
57
  group: "help",
33
58
  action: undefined,
34
59
  options: { command: maybeAction, ...parseOptions(rest) },
35
- };
60
+ });
36
61
  }
37
62
 
38
63
  const hasAction = GROUP_ACTIONS.has(group);
@@ -47,21 +72,21 @@ export function parseArgv(argv) {
47
72
  const hasHelpOption = optionTokens.some((token) => token === "--help" || token === "-h")
48
73
  || (optionTokens.length === 1 && optionTokens[0] === "help");
49
74
  if (hasHelpOption) {
50
- return {
75
+ return validateCommandOptions({
51
76
  group,
52
77
  action,
53
78
  options: {
54
79
  ...parseOptions(optionTokens.filter((token) => token !== "help" && token !== "--help" && token !== "-h")),
55
80
  help: true,
56
81
  },
57
- };
82
+ });
58
83
  }
59
84
 
60
- return {
85
+ return validateCommandOptions({
61
86
  group,
62
87
  action,
63
88
  options: parseOptions(optionTokens),
64
- };
89
+ });
65
90
  }
66
91
 
67
92
  function isHelpToken(token) {
@@ -96,6 +121,20 @@ function parseOptions(tokens) {
96
121
  return options;
97
122
  }
98
123
 
124
+ function validateCommandOptions(command) {
125
+ const key = command.action ? `${command.group} ${command.action}` : command.group;
126
+ const allowed = COMMAND_OPTIONS[key] ?? new Set();
127
+ for (const option of Object.keys(command.options)) {
128
+ if (GLOBAL_OPTIONS.has(option) || allowed.has(option)) continue;
129
+ const flag = `--${option.replaceAll("_", "-")}`;
130
+ const helpCommand = command.action
131
+ ? `flatkey ${command.group} ${command.action} --help`
132
+ : `flatkey ${command.group} --help`;
133
+ throw new Error(`Unknown option ${flag} for flatkey ${key}. Run \`${helpCommand}\` to see supported options.`);
134
+ }
135
+ return command;
136
+ }
137
+
99
138
  export async function main(argv) {
100
139
  const command = parseArgv(argv);
101
140
  if (command.options.help) {
@@ -140,6 +179,21 @@ export async function runCommand(command, deps = {}) {
140
179
  return handleModels(command, deps);
141
180
  }
142
181
 
182
+ if (command.group === "login") {
183
+ return handleLogin(command, { ...deps, stdout, stderr });
184
+ }
185
+
186
+ if (command.group === "logout") {
187
+ return handleLogout(command, deps);
188
+ }
189
+
190
+ if (command.group === "auth") {
191
+ if (command.action !== "status") {
192
+ throw new Error(`Unknown action for auth: ${command.action}`);
193
+ }
194
+ return handleAuthStatus(command, deps);
195
+ }
196
+
143
197
  if (command.group === "credits" || command.group === "status") {
144
198
  return handleUtility(command, deps);
145
199
  }
@@ -160,6 +214,150 @@ export async function runCommand(command, deps = {}) {
160
214
  throw new Error(`Unknown command: ${command.group}`);
161
215
  }
162
216
 
217
+ async function handleLogin(command, deps) {
218
+ const { ensureDeviceId, writeAuthConfig } = await import("./config.js");
219
+ const { createDeviceAuthorization, pollDeviceAuthorization } = await import("./api.js");
220
+ const deviceId = await ensureDeviceId({ configDir: deps.configDir });
221
+ const version = await readPackageVersion();
222
+ const consoleUrl = command.options.console_url;
223
+ const authorization = await createDeviceAuthorization({
224
+ consoleUrl,
225
+ deviceId,
226
+ clientName: "flatkey-cli",
227
+ clientVersion: version,
228
+ fetch: deps.fetch,
229
+ });
230
+ const data = authorization?.data ?? authorization;
231
+ if (!data?.device_code || !data?.verification_uri_complete) {
232
+ throw new Error("Flatkey login failed: missing device authorization response.");
233
+ }
234
+
235
+ if (!command.options.json) {
236
+ deps.stdout?.write?.(`Open this URL to approve Flatkey CLI:\n${data.verification_uri_complete}\n\n`);
237
+ }
238
+ if (command.options.open !== false && command.options.no_open !== true) {
239
+ await openBrowser(data.verification_uri_complete, deps);
240
+ }
241
+
242
+ const initialIntervalMs = Math.max(Number(data.interval ?? 5), 5) * 1000;
243
+ const deadline = Date.now() + Math.max(Number(data.expires_in ?? 600), 1) * 1000;
244
+ const startedAt = Date.now();
245
+ while (Date.now() < deadline) {
246
+ await delay(nextLoginPollDelay(startedAt, initialIntervalMs), deps);
247
+ const poll = await pollDeviceAuthorization({
248
+ consoleUrl,
249
+ deviceCode: data.device_code,
250
+ fetch: deps.fetch,
251
+ });
252
+ const pollData = poll?.data ?? poll;
253
+ if (pollData?.status === "approved") {
254
+ if (!pollData.api_key) {
255
+ throw new Error("Flatkey login approved but no API key was returned.");
256
+ }
257
+ const configPath = await writeAuthConfig({
258
+ apiKey: pollData.api_key,
259
+ auth: {
260
+ deviceId,
261
+ userId: pollData.user_id,
262
+ tokenId: pollData.token_id,
263
+ loginAt: Math.floor(Date.now() / 1000),
264
+ },
265
+ configDir: deps.configDir,
266
+ });
267
+ return command.options.json
268
+ ? { success: true, configPath, tokenId: pollData.token_id, userId: pollData.user_id }
269
+ : `Flatkey CLI authorized. Saved config: ${configPath}`;
270
+ }
271
+ if (pollData?.status === "denied") {
272
+ throw new Error("Flatkey login denied.");
273
+ }
274
+ if (pollData?.status === "expired") {
275
+ throw new Error("Flatkey login expired. Run `flatkey login` again.");
276
+ }
277
+ }
278
+ throw new Error("Flatkey login timed out. Run `flatkey login` again.");
279
+ }
280
+
281
+ function nextLoginPollDelay(startedAt, initialIntervalMs) {
282
+ const elapsed = Date.now() - startedAt;
283
+ const base = elapsed < 30_000
284
+ ? initialIntervalMs
285
+ : elapsed < 120_000
286
+ ? Math.max(initialIntervalMs, 10_000)
287
+ : Math.max(initialIntervalMs, 15_000);
288
+ return base + Math.floor(Math.random() * 800);
289
+ }
290
+
291
+ async function delay(ms, deps = {}) {
292
+ const sleep = deps.sleep ?? ((duration) => new Promise((resolve) => setTimeout(resolve, duration)));
293
+ return sleep(ms);
294
+ }
295
+
296
+ async function openBrowser(url, deps = {}) {
297
+ if (deps.openBrowser) return deps.openBrowser(url);
298
+ const { spawn } = await import("node:child_process");
299
+ const platform = deps.platform ?? process.platform;
300
+ const command = platform === "darwin"
301
+ ? "open"
302
+ : platform === "win32"
303
+ ? "cmd"
304
+ : "xdg-open";
305
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
306
+ try {
307
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
308
+ child.unref();
309
+ } catch {
310
+ // Printed URL is enough when open is unavailable.
311
+ }
312
+ }
313
+
314
+ function maskKey(key) {
315
+ if (!key) return "";
316
+ if (key.length <= 8) return `${key.slice(0, 2)}****${key.slice(-2)}`;
317
+ return `${key.slice(0, 6)}...${key.slice(-4)}`;
318
+ }
319
+
320
+ function formatAuthStatus(status) {
321
+ if (!status.authenticated) return "Not authenticated";
322
+ return `Authenticated via ${status.source}: ${status.key}`;
323
+ }
324
+
325
+ async function handleLogout(command, deps = {}) {
326
+ const { clearSavedApiKey } = await import("./config.js");
327
+ const configPath = await clearSavedApiKey({ configDir: deps.configDir });
328
+ return command.options.json
329
+ ? { success: true, configPath }
330
+ : `Removed saved Flatkey API key from ${configPath}`;
331
+ }
332
+
333
+ async function handleAuthStatus(command, deps) {
334
+ const { readConfig, resolveApiKey } = await import("./config.js");
335
+ const saved = await readConfig(deps.configDir);
336
+ let apiKey;
337
+ try {
338
+ apiKey = await resolveApiKey({
339
+ apiKey: command.options.api_key,
340
+ env: deps.env ?? process.env,
341
+ configDir: deps.configDir,
342
+ });
343
+ } catch {
344
+ apiKey = "";
345
+ }
346
+ const status = {
347
+ authenticated: Boolean(apiKey),
348
+ source: command.options.api_key
349
+ ? "option"
350
+ : (deps.env ?? process.env).FLATKEY_API_KEY
351
+ ? "env"
352
+ : saved?.apiKey
353
+ ? "config"
354
+ : "none",
355
+ key: maskKey(apiKey),
356
+ auth: saved?.auth ?? null,
357
+ };
358
+ return command.options.json ? status : formatAuthStatus(status);
359
+ }
360
+
163
361
  async function handleGenerate(command, deps) {
164
362
  const { resolveApiKey } = await import("./config.js");
165
363
  const {
@@ -233,12 +431,33 @@ async function handleGenerate(command, deps) {
233
431
  output: command.options.output,
234
432
  fetch: deps.fetch,
235
433
  });
236
- return { kind: command.group, artifacts, response };
434
+ return { kind: command.group, artifacts, response: scrubArtifactResponse(response) };
237
435
  } finally {
238
436
  animation.stop();
239
437
  }
240
438
  }
241
439
 
440
+ function scrubArtifactResponse(value) {
441
+ if (Array.isArray(value)) return value.map(scrubArtifactResponse);
442
+ if (!value || typeof value !== "object") {
443
+ if (typeof value === "string" && value.startsWith("data:")) return "<artifact omitted>";
444
+ return value;
445
+ }
446
+ return Object.fromEntries(
447
+ Object.entries(value).map(([key, entry]) => {
448
+ if (["b64_json", "base64", "data"].includes(key) && typeof entry === "string") {
449
+ return [key, "<artifact omitted>"];
450
+ }
451
+ if (["url", "data_url", "dataUrl"].includes(key)
452
+ && typeof entry === "string"
453
+ && entry.startsWith("data:")) {
454
+ return [key, "<artifact omitted>"];
455
+ }
456
+ return [key, scrubArtifactResponse(entry)];
457
+ }),
458
+ );
459
+ }
460
+
242
461
  async function handleVoices(command, deps) {
243
462
  const { resolveApiKey } = await import("./config.js");
244
463
  const { getVoices } = await import("./api.js");
@@ -262,6 +481,7 @@ function extractText(response) {
262
481
 
263
482
  async function writeTextOutput(text, output) {
264
483
  if (!output) return undefined;
484
+ output = await expandHomePath(output);
265
485
  const { mkdir, writeFile } = await import("node:fs/promises");
266
486
  const { dirname } = await import("node:path");
267
487
  await mkdir(dirname(output), { recursive: true });
@@ -269,6 +489,15 @@ async function writeTextOutput(text, output) {
269
489
  return output;
270
490
  }
271
491
 
492
+ async function expandHomePath(path) {
493
+ if (typeof path !== "string") return path;
494
+ if (path !== "~" && !path.startsWith("~/")) return path;
495
+ const { homedir } = await import("node:os");
496
+ const { join } = await import("node:path");
497
+ if (path === "~") return homedir();
498
+ return join(homedir(), path.slice(2));
499
+ }
500
+
272
501
  function redactRequest(request) {
273
502
  return {
274
503
  ...request,
package/src/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
 
@@ -44,6 +45,62 @@ export async function writeConfig({ apiKey, configDir = getDefaultConfigDir() })
44
45
  return configPath;
45
46
  }
46
47
 
48
+ export async function writeAuthConfig({
49
+ apiKey,
50
+ auth,
51
+ configDir = getDefaultConfigDir(),
52
+ }) {
53
+ if (typeof apiKey !== "string" || apiKey.trim() === "") {
54
+ throw new Error("Missing API key from Flatkey login response.");
55
+ }
56
+ const saved = await readSavedConfig(configDir) ?? {};
57
+ const next = {
58
+ ...saved,
59
+ apiKey,
60
+ auth: {
61
+ ...(saved.auth ?? {}),
62
+ ...auth,
63
+ type: "device",
64
+ },
65
+ };
66
+ await mkdir(configDir, { recursive: true });
67
+ const configPath = getConfigPath(configDir);
68
+ await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, {
69
+ mode: 0o600,
70
+ });
71
+ try {
72
+ await chmod(configPath, 0o600);
73
+ } catch (error) {
74
+ if (process.platform !== "win32") throw error;
75
+ }
76
+ return configPath;
77
+ }
78
+
79
+ export async function ensureDeviceId({ configDir = getDefaultConfigDir() } = {}) {
80
+ const saved = await readSavedConfig(configDir);
81
+ if (typeof saved?.auth?.deviceId === "string" && saved.auth.deviceId) {
82
+ return saved.auth.deviceId;
83
+ }
84
+ return randomUUID();
85
+ }
86
+
87
+ export async function clearSavedApiKey({ configDir = getDefaultConfigDir() } = {}) {
88
+ const saved = await readSavedConfig(configDir);
89
+ if (!saved) return getConfigPath(configDir);
90
+ const next = { ...saved };
91
+ delete next.apiKey;
92
+ await mkdir(configDir, { recursive: true });
93
+ const configPath = getConfigPath(configDir);
94
+ await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, {
95
+ mode: 0o600,
96
+ });
97
+ return configPath;
98
+ }
99
+
100
+ export async function readConfig(configDir = getDefaultConfigDir()) {
101
+ return readSavedConfig(configDir);
102
+ }
103
+
47
104
  async function readSavedConfig(configDir) {
48
105
  try {
49
106
  return JSON.parse(await readFile(getConfigPath(configDir), "utf8"));
package/src/help.js CHANGED
@@ -2,7 +2,8 @@ export function getAiHelp() {
2
2
  return `Flatkey CLI protocol for agents
3
3
 
4
4
  Setup:
5
- - Prefer env: FLATKEY_API_KEY=<key>
5
+ - Prefer browser auth: flatkey login
6
+ - Or env: FLATKEY_API_KEY=<key>
6
7
  - Create a key at https://console.flatkey.ai/keys
7
8
  - Or save key: flatkey onboard --api-key <key>
8
9
  - Use --json for machine-readable output.
@@ -23,6 +24,9 @@ Commands:
23
24
  - flatkey credits --json
24
25
  - flatkey status --json
25
26
  - flatkey models --json [--type image|video|audio|text]
27
+ - flatkey login [--no-open] [--console-url <url>]
28
+ - flatkey logout
29
+ - flatkey auth status --json
26
30
  - flatkey help --ai
27
31
 
28
32
  Environment:
@@ -41,6 +45,9 @@ export function getHumanHelp() {
41
45
  return `Usage: flatkey <command> [options]
42
46
 
43
47
  Commands:
48
+ login Authorize CLI in browser
49
+ logout Remove saved Flatkey API key
50
+ auth status Show saved auth state
44
51
  onboard --api-key <key> Save Flatkey API key from https://console.flatkey.ai/keys
45
52
  image generate --prompt <txt> Generate image
46
53
  video generate --prompt <txt> Generate video
@@ -58,6 +65,7 @@ Global options:
58
65
  --json Print machine-readable JSON
59
66
  --output, -o <file> Write generated output to a local file
60
67
  --base-url <url> Override Flatkey router URL
68
+ --console-url <url> Override Flatkey console URL for login
61
69
 
62
70
  Video options:
63
71
  --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
@@ -110,6 +118,17 @@ Options:
110
118
  --json Print machine-readable JSON`,
111
119
  credits: `Usage: flatkey credits [options]
112
120
 
121
+ Options:
122
+ --json Print machine-readable JSON`,
123
+ auth: `Usage: flatkey auth <action> [options]
124
+
125
+ Actions:
126
+ status Show saved auth state
127
+
128
+ Options:
129
+ --json Print machine-readable JSON`,
130
+ "auth status": `Usage: flatkey auth status [options]
131
+
113
132
  Options:
114
133
  --json Print machine-readable JSON`,
115
134
  help: `Usage: flatkey help [command] [options]
@@ -138,6 +157,16 @@ Options:
138
157
 
139
158
  Options:
140
159
  --type <type> Filter: image, video, audio, or text
160
+ --json Print machine-readable JSON`,
161
+ login: `Usage: flatkey login [options]
162
+
163
+ Options:
164
+ --no-open Print approval URL without opening a browser
165
+ --console-url <url> Override Flatkey console URL
166
+ --json Print machine-readable JSON`,
167
+ logout: `Usage: flatkey logout [options]
168
+
169
+ Options:
141
170
  --json Print machine-readable JSON`,
142
171
  onboard: `Usage: flatkey onboard --api-key <key>
143
172