@flatkey-ai/cli 0.1.6 → 0.1.7

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.7",
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,18 @@ 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"]);
15
18
 
16
19
  export function parseArgv(argv) {
17
20
  const [group, maybeAction, ...rest] = argv;
@@ -140,6 +143,21 @@ export async function runCommand(command, deps = {}) {
140
143
  return handleModels(command, deps);
141
144
  }
142
145
 
146
+ if (command.group === "login") {
147
+ return handleLogin(command, { ...deps, stdout, stderr });
148
+ }
149
+
150
+ if (command.group === "logout") {
151
+ return handleLogout(command, deps);
152
+ }
153
+
154
+ if (command.group === "auth") {
155
+ if (command.action !== "status") {
156
+ throw new Error(`Unknown action for auth: ${command.action}`);
157
+ }
158
+ return handleAuthStatus(command, deps);
159
+ }
160
+
143
161
  if (command.group === "credits" || command.group === "status") {
144
162
  return handleUtility(command, deps);
145
163
  }
@@ -160,6 +178,150 @@ export async function runCommand(command, deps = {}) {
160
178
  throw new Error(`Unknown command: ${command.group}`);
161
179
  }
162
180
 
181
+ async function handleLogin(command, deps) {
182
+ const { ensureDeviceId, writeAuthConfig } = await import("./config.js");
183
+ const { createDeviceAuthorization, pollDeviceAuthorization } = await import("./api.js");
184
+ const deviceId = await ensureDeviceId({ configDir: deps.configDir });
185
+ const version = await readPackageVersion();
186
+ const consoleUrl = command.options.console_url;
187
+ const authorization = await createDeviceAuthorization({
188
+ consoleUrl,
189
+ deviceId,
190
+ clientName: "flatkey-cli",
191
+ clientVersion: version,
192
+ fetch: deps.fetch,
193
+ });
194
+ const data = authorization?.data ?? authorization;
195
+ if (!data?.device_code || !data?.verification_uri_complete) {
196
+ throw new Error("Flatkey login failed: missing device authorization response.");
197
+ }
198
+
199
+ if (!command.options.json) {
200
+ deps.stdout?.write?.(`Open this URL to approve Flatkey CLI:\n${data.verification_uri_complete}\n\n`);
201
+ }
202
+ if (command.options.open !== false && command.options.no_open !== true) {
203
+ await openBrowser(data.verification_uri_complete, deps);
204
+ }
205
+
206
+ const initialIntervalMs = Math.max(Number(data.interval ?? 5), 5) * 1000;
207
+ const deadline = Date.now() + Math.max(Number(data.expires_in ?? 600), 1) * 1000;
208
+ const startedAt = Date.now();
209
+ while (Date.now() < deadline) {
210
+ await delay(nextLoginPollDelay(startedAt, initialIntervalMs), deps);
211
+ const poll = await pollDeviceAuthorization({
212
+ consoleUrl,
213
+ deviceCode: data.device_code,
214
+ fetch: deps.fetch,
215
+ });
216
+ const pollData = poll?.data ?? poll;
217
+ if (pollData?.status === "approved") {
218
+ if (!pollData.api_key) {
219
+ throw new Error("Flatkey login approved but no API key was returned.");
220
+ }
221
+ const configPath = await writeAuthConfig({
222
+ apiKey: pollData.api_key,
223
+ auth: {
224
+ deviceId,
225
+ userId: pollData.user_id,
226
+ tokenId: pollData.token_id,
227
+ loginAt: Math.floor(Date.now() / 1000),
228
+ },
229
+ configDir: deps.configDir,
230
+ });
231
+ return command.options.json
232
+ ? { success: true, configPath, tokenId: pollData.token_id, userId: pollData.user_id }
233
+ : `Flatkey CLI authorized. Saved config: ${configPath}`;
234
+ }
235
+ if (pollData?.status === "denied") {
236
+ throw new Error("Flatkey login denied.");
237
+ }
238
+ if (pollData?.status === "expired") {
239
+ throw new Error("Flatkey login expired. Run `flatkey login` again.");
240
+ }
241
+ }
242
+ throw new Error("Flatkey login timed out. Run `flatkey login` again.");
243
+ }
244
+
245
+ function nextLoginPollDelay(startedAt, initialIntervalMs) {
246
+ const elapsed = Date.now() - startedAt;
247
+ const base = elapsed < 30_000
248
+ ? initialIntervalMs
249
+ : elapsed < 120_000
250
+ ? Math.max(initialIntervalMs, 10_000)
251
+ : Math.max(initialIntervalMs, 15_000);
252
+ return base + Math.floor(Math.random() * 800);
253
+ }
254
+
255
+ async function delay(ms, deps = {}) {
256
+ const sleep = deps.sleep ?? ((duration) => new Promise((resolve) => setTimeout(resolve, duration)));
257
+ return sleep(ms);
258
+ }
259
+
260
+ async function openBrowser(url, deps = {}) {
261
+ if (deps.openBrowser) return deps.openBrowser(url);
262
+ const { spawn } = await import("node:child_process");
263
+ const platform = deps.platform ?? process.platform;
264
+ const command = platform === "darwin"
265
+ ? "open"
266
+ : platform === "win32"
267
+ ? "cmd"
268
+ : "xdg-open";
269
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
270
+ try {
271
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
272
+ child.unref();
273
+ } catch {
274
+ // Printed URL is enough when open is unavailable.
275
+ }
276
+ }
277
+
278
+ function maskKey(key) {
279
+ if (!key) return "";
280
+ if (key.length <= 8) return `${key.slice(0, 2)}****${key.slice(-2)}`;
281
+ return `${key.slice(0, 6)}...${key.slice(-4)}`;
282
+ }
283
+
284
+ function formatAuthStatus(status) {
285
+ if (!status.authenticated) return "Not authenticated";
286
+ return `Authenticated via ${status.source}: ${status.key}`;
287
+ }
288
+
289
+ async function handleLogout(command, deps = {}) {
290
+ const { clearSavedApiKey } = await import("./config.js");
291
+ const configPath = await clearSavedApiKey({ configDir: deps.configDir });
292
+ return command.options.json
293
+ ? { success: true, configPath }
294
+ : `Removed saved Flatkey API key from ${configPath}`;
295
+ }
296
+
297
+ async function handleAuthStatus(command, deps) {
298
+ const { readConfig, resolveApiKey } = await import("./config.js");
299
+ const saved = await readConfig(deps.configDir);
300
+ let apiKey;
301
+ try {
302
+ apiKey = await resolveApiKey({
303
+ apiKey: command.options.api_key,
304
+ env: deps.env ?? process.env,
305
+ configDir: deps.configDir,
306
+ });
307
+ } catch {
308
+ apiKey = "";
309
+ }
310
+ const status = {
311
+ authenticated: Boolean(apiKey),
312
+ source: command.options.api_key
313
+ ? "option"
314
+ : (deps.env ?? process.env).FLATKEY_API_KEY
315
+ ? "env"
316
+ : saved?.apiKey
317
+ ? "config"
318
+ : "none",
319
+ key: maskKey(apiKey),
320
+ auth: saved?.auth ?? null,
321
+ };
322
+ return command.options.json ? status : formatAuthStatus(status);
323
+ }
324
+
163
325
  async function handleGenerate(command, deps) {
164
326
  const { resolveApiKey } = await import("./config.js");
165
327
  const {
@@ -233,12 +395,33 @@ async function handleGenerate(command, deps) {
233
395
  output: command.options.output,
234
396
  fetch: deps.fetch,
235
397
  });
236
- return { kind: command.group, artifacts, response };
398
+ return { kind: command.group, artifacts, response: scrubArtifactResponse(response) };
237
399
  } finally {
238
400
  animation.stop();
239
401
  }
240
402
  }
241
403
 
404
+ function scrubArtifactResponse(value) {
405
+ if (Array.isArray(value)) return value.map(scrubArtifactResponse);
406
+ if (!value || typeof value !== "object") {
407
+ if (typeof value === "string" && value.startsWith("data:")) return "<artifact omitted>";
408
+ return value;
409
+ }
410
+ return Object.fromEntries(
411
+ Object.entries(value).map(([key, entry]) => {
412
+ if (["b64_json", "base64", "data"].includes(key) && typeof entry === "string") {
413
+ return [key, "<artifact omitted>"];
414
+ }
415
+ if (["url", "data_url", "dataUrl"].includes(key)
416
+ && typeof entry === "string"
417
+ && entry.startsWith("data:")) {
418
+ return [key, "<artifact omitted>"];
419
+ }
420
+ return [key, scrubArtifactResponse(entry)];
421
+ }),
422
+ );
423
+ }
424
+
242
425
  async function handleVoices(command, deps) {
243
426
  const { resolveApiKey } = await import("./config.js");
244
427
  const { getVoices } = await import("./api.js");
@@ -262,6 +445,7 @@ function extractText(response) {
262
445
 
263
446
  async function writeTextOutput(text, output) {
264
447
  if (!output) return undefined;
448
+ output = await expandHomePath(output);
265
449
  const { mkdir, writeFile } = await import("node:fs/promises");
266
450
  const { dirname } = await import("node:path");
267
451
  await mkdir(dirname(output), { recursive: true });
@@ -269,6 +453,15 @@ async function writeTextOutput(text, output) {
269
453
  return output;
270
454
  }
271
455
 
456
+ async function expandHomePath(path) {
457
+ if (typeof path !== "string") return path;
458
+ if (path !== "~" && !path.startsWith("~/")) return path;
459
+ const { homedir } = await import("node:os");
460
+ const { join } = await import("node:path");
461
+ if (path === "~") return homedir();
462
+ return join(homedir(), path.slice(2));
463
+ }
464
+
272
465
  function redactRequest(request) {
273
466
  return {
274
467
  ...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