@flatkey-ai/cli 0.1.0 → 0.1.2

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 CHANGED
@@ -30,8 +30,10 @@ export FLATKEY_API_KEY=<key>
30
30
 
31
31
  ```bash
32
32
  flatkey image generate --prompt "magazine cover" --json
33
+ flatkey image generate --prompt "magazine cover" --output cover.png --json
33
34
  flatkey video generate --prompt "product launch clip" --json
34
35
  flatkey audio generate --prompt "30 second voiceover" --json
36
+ flatkey text generate --prompt "write a headline" --model gpt-5.5 --json
35
37
  flatkey credits --json
36
38
  flatkey status --json
37
39
  flatkey models --json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flatkey-ai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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
@@ -9,9 +9,13 @@ export class FlatkeyError extends Error {
9
9
  }
10
10
 
11
11
  export async function generateImage(options) {
12
+ return requestJsonFromPlan(options, planImageRequest(options));
13
+ }
14
+
15
+ export function planImageRequest(options) {
12
16
  const model = options.model ?? "nano-banana-pro-preview";
13
17
  if (model.startsWith("gpt")) {
14
- return postJson(options, "/v1/images/generations", cleanObject({
18
+ return planJsonPost(options, "/v1/images/generations", cleanObject({
15
19
  model,
16
20
  prompt: options.prompt,
17
21
  size: options.size,
@@ -21,17 +25,21 @@ export async function generateImage(options) {
21
25
  }
22
26
 
23
27
  const path = `/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
24
- return requestJson(options, path, {
28
+ return planRequest(options, path, {
25
29
  method: "POST",
26
30
  headers: { "content-type": "application/json" },
27
- body: JSON.stringify({
31
+ body: {
28
32
  contents: [{ parts: [{ text: options.prompt }] }],
29
- }),
33
+ },
30
34
  });
31
35
  }
32
36
 
33
37
  export function generateVideo(options) {
34
- return postJson(options, "/v1/videos/generations", cleanObject({
38
+ return requestJsonFromPlan(options, planVideoRequest(options));
39
+ }
40
+
41
+ export function planVideoRequest(options) {
42
+ return planJsonPost(options, "/v1/videos/generations", cleanObject({
35
43
  model: options.model ?? "veo-3",
36
44
  prompt: options.prompt,
37
45
  duration: parseOptionalInteger(options.duration),
@@ -41,7 +49,11 @@ export function generateVideo(options) {
41
49
  }
42
50
 
43
51
  export function generateAudio(options) {
44
- return postJson(options, "/v1/audio/generations", cleanObject({
52
+ return requestJsonFromPlan(options, planAudioRequest(options));
53
+ }
54
+
55
+ export function planAudioRequest(options) {
56
+ return planJsonPost(options, "/v1/audio/generations", cleanObject({
45
57
  model: options.model ?? "tts-1",
46
58
  prompt: options.prompt,
47
59
  voice: options.voice,
@@ -49,6 +61,17 @@ export function generateAudio(options) {
49
61
  }));
50
62
  }
51
63
 
64
+ export function generateText(options) {
65
+ return requestJsonFromPlan(options, planTextRequest(options));
66
+ }
67
+
68
+ export function planTextRequest(options) {
69
+ return planJsonPost(options, "/v1/chat/completions", {
70
+ model: options.model ?? "gpt-5.5",
71
+ messages: [{ role: "user", content: options.prompt }],
72
+ });
73
+ }
74
+
52
75
  export function getCredits(options) {
53
76
  return requestJson(options, "/v1/credits");
54
77
  }
@@ -62,22 +85,32 @@ export function getModels(options) {
62
85
  }
63
86
 
64
87
  async function postJson(options, path, payload) {
65
- return requestJson(options, path, {
88
+ return requestJsonFromPlan(options, planJsonPost(options, path, payload));
89
+ }
90
+
91
+ function planJsonPost(options, path, payload) {
92
+ return planRequest(options, path, {
66
93
  method: "POST",
67
94
  headers: jsonHeaders(options.apiKey),
68
- body: JSON.stringify(payload),
95
+ body: payload,
69
96
  });
70
97
  }
71
98
 
72
- async function requestJson(options, path, init = {}) {
99
+ function planRequest(options, path, init = {}) {
100
+ return {
101
+ url: buildUrl(options.baseUrl, path),
102
+ method: init.method ?? "GET",
103
+ headers: init.headers ?? authHeaders(options.apiKey),
104
+ body: init.body,
105
+ };
106
+ }
107
+
108
+ async function requestJsonFromPlan(options, plan) {
73
109
  const fetchImpl = options.fetch ?? fetch;
74
- const response = await fetchImpl(buildUrl(options.baseUrl, path), {
75
- method: "GET",
76
- ...init,
77
- headers: {
78
- ...authHeaders(options.apiKey),
79
- ...init.headers,
80
- },
110
+ const response = await fetchImpl(plan.url, {
111
+ method: plan.method,
112
+ headers: plan.headers,
113
+ body: plan.body === undefined ? undefined : JSON.stringify(plan.body),
81
114
  });
82
115
  const body = await readJson(response);
83
116
  if (!response.ok) {
@@ -88,6 +121,17 @@ async function requestJson(options, path, init = {}) {
88
121
  return body;
89
122
  }
90
123
 
124
+ async function requestJson(options, path, init = {}) {
125
+ return requestJsonFromPlan(options, planRequest(options, path, {
126
+ method: init.method ?? "GET",
127
+ headers: {
128
+ ...authHeaders(options.apiKey),
129
+ ...init.headers,
130
+ },
131
+ body: init.body ? JSON.parse(init.body) : undefined,
132
+ }));
133
+ }
134
+
91
135
  function buildUrl(baseUrl = DEFAULT_BASE_URL, path) {
92
136
  return `${baseUrl.replace(/\/$/, "")}${path}`;
93
137
  }
package/src/artifacts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { dirname, extname, join } from "node:path";
3
3
 
4
4
  const DEFAULT_EXTENSIONS = {
5
5
  audio: "mp3",
@@ -18,34 +18,51 @@ const MIME_EXTENSIONS = {
18
18
  "video/mp4": "mp4",
19
19
  };
20
20
 
21
- export async function persistArtifacts({ kind, response, outDir = "flatkey-output" }) {
21
+ export async function persistArtifacts({
22
+ kind,
23
+ response,
24
+ outDir = "flatkey-output",
25
+ output,
26
+ fetch: fetchImpl = fetch,
27
+ }) {
22
28
  const items = extractItems(response);
23
29
  const artifacts = [];
24
- await mkdir(outDir, { recursive: true });
30
+ await mkdir(output ? dirname(output) : outDir, { recursive: true });
25
31
 
26
32
  for (const [index, item] of items.entries()) {
27
- const artifact = await persistItem({ kind, item, outDir, index });
33
+ const artifact = await persistItem({ kind, item, outDir, output, index, fetchImpl });
28
34
  if (artifact) artifacts.push(artifact);
29
35
  }
30
36
 
31
37
  return artifacts;
32
38
  }
33
39
 
34
- async function persistItem({ kind, item, outDir, index }) {
40
+ async function persistItem({ kind, item, outDir, output, index, fetchImpl }) {
35
41
  const dataUrl = getString(item, ["url", "data_url", "dataUrl"]);
36
42
  if (dataUrl?.startsWith("data:")) {
37
43
  const parsed = parseDataUrl(dataUrl);
38
- const path = artifactPath({ kind, outDir, index, extension: parsed.extension });
44
+ const path = artifactPath({ kind, outDir, output, index, extension: parsed.extension });
39
45
  await writeFile(path, parsed.buffer);
40
46
  return { path };
41
47
  }
42
48
  if (dataUrl?.startsWith("http://") || dataUrl?.startsWith("https://")) {
49
+ if (output) {
50
+ const path = artifactPath({
51
+ kind,
52
+ outDir,
53
+ output,
54
+ index,
55
+ extension: DEFAULT_EXTENSIONS[kind] ?? "bin",
56
+ });
57
+ await downloadArtifact({ url: dataUrl, path, fetchImpl });
58
+ return { path };
59
+ }
43
60
  return { url: dataUrl };
44
61
  }
45
62
 
46
63
  const base64 = getString(item, ["b64_json", "base64", "data"]);
47
64
  if (base64) {
48
- const path = artifactPath({ kind, outDir, index, extension: DEFAULT_EXTENSIONS[kind] });
65
+ const path = artifactPath({ kind, outDir, output, index, extension: DEFAULT_EXTENSIONS[kind] });
49
66
  await writeFile(path, Buffer.from(base64, "base64"));
50
67
  return { path };
51
68
  }
@@ -53,6 +70,14 @@ async function persistItem({ kind, item, outDir, index }) {
53
70
  return undefined;
54
71
  }
55
72
 
73
+ async function downloadArtifact({ url, path, fetchImpl }) {
74
+ const response = await fetchImpl(url);
75
+ if (!response.ok) {
76
+ throw new Error(`Failed to download artifact ${url}: HTTP ${response.status}`);
77
+ }
78
+ await writeFile(path, Buffer.from(await response.arrayBuffer()));
79
+ }
80
+
56
81
  function extractItems(response) {
57
82
  if (Array.isArray(response?.data)) return response.data;
58
83
  if (Array.isArray(response?.artifacts)) return response.artifacts;
@@ -80,7 +105,14 @@ function parseDataUrl(value) {
80
105
  };
81
106
  }
82
107
 
83
- function artifactPath({ kind, outDir, index, extension }) {
108
+ function artifactPath({ kind, outDir, output, index, extension }) {
109
+ if (output) {
110
+ if (index === 0) return output;
111
+ const existingExtension = extname(output);
112
+ const base = existingExtension ? output.slice(0, -existingExtension.length) : output;
113
+ const suffix = String(index + 1).padStart(2, "0");
114
+ return `${base}-${suffix}${existingExtension || `.${extension}`}`;
115
+ }
84
116
  const number = String(index + 1).padStart(2, "0");
85
117
  return join(outDir, `${kind}-${number}.${extension}`);
86
118
  }
package/src/cli.js CHANGED
@@ -6,11 +6,12 @@ const COMMANDS = new Set([
6
6
  "models",
7
7
  "onboard",
8
8
  "status",
9
+ "text",
9
10
  "version",
10
11
  "video",
11
12
  ]);
12
13
 
13
- const GROUP_ACTIONS = new Set(["audio", "image", "video"]);
14
+ const GROUP_ACTIONS = new Set(["audio", "image", "text", "video"]);
14
15
 
15
16
  export function parseArgv(argv) {
16
17
  const [group, maybeAction, ...rest] = argv;
@@ -36,6 +37,15 @@ function parseOptions(tokens) {
36
37
  const options = {};
37
38
  for (let index = 0; index < tokens.length; index += 1) {
38
39
  const token = tokens[index];
40
+ if (token === "-o") {
41
+ const output = tokens[index + 1];
42
+ if (!output || output.startsWith("-")) {
43
+ throw new Error("Missing value for -o.");
44
+ }
45
+ options.output = output;
46
+ index += 1;
47
+ continue;
48
+ }
39
49
  if (!token.startsWith("--")) {
40
50
  throw new Error(`Unexpected argument: ${token}`);
41
51
  }
@@ -77,7 +87,7 @@ export async function runCommand(command, deps = {}) {
77
87
  return command.options.ai ? getAiHelp() : getHumanHelp();
78
88
  }
79
89
  if (command.group === "version") {
80
- return { version: "0.1.0" };
90
+ return { version: await readPackageVersion() };
81
91
  }
82
92
 
83
93
  if (command.group === "models") {
@@ -88,7 +98,7 @@ export async function runCommand(command, deps = {}) {
88
98
  return handleUtility(command, deps);
89
99
  }
90
100
 
91
- if (["image", "video", "audio"].includes(command.group)) {
101
+ if (["image", "video", "audio", "text"].includes(command.group)) {
92
102
  if (command.action !== "generate") {
93
103
  throw new Error(`Unknown action for ${command.group}: ${command.action}`);
94
104
  }
@@ -100,19 +110,40 @@ export async function runCommand(command, deps = {}) {
100
110
 
101
111
  async function handleGenerate(command, deps) {
102
112
  const { resolveApiKey } = await import("./config.js");
103
- const { generateAudio, generateImage, generateVideo } = await import("./api.js");
113
+ const {
114
+ generateAudio,
115
+ generateImage,
116
+ generateText,
117
+ generateVideo,
118
+ planAudioRequest,
119
+ planImageRequest,
120
+ planTextRequest,
121
+ planVideoRequest,
122
+ } = await import("./api.js");
104
123
  const { persistArtifacts } = await import("./artifacts.js");
105
124
  const { createAnimation } = await import("./animation.js");
106
- const apiKey = await resolveApiKey({
107
- apiKey: command.options.api_key,
108
- env: deps.env ?? process.env,
109
- });
125
+ const apiKey = command.options.dry_run
126
+ ? (command.options.api_key ?? "FLATKEY_API_KEY")
127
+ : await resolveApiKey({
128
+ apiKey: command.options.api_key,
129
+ env: deps.env ?? process.env,
130
+ });
110
131
  const options = {
111
132
  ...command.options,
112
133
  apiKey,
113
134
  baseUrl: command.options.base_url,
114
135
  fetch: deps.fetch,
115
136
  };
137
+ if (command.options.dry_run) {
138
+ const request = command.group === "image"
139
+ ? planImageRequest(options)
140
+ : command.group === "video"
141
+ ? planVideoRequest(options)
142
+ : command.group === "audio"
143
+ ? planAudioRequest(options)
144
+ : planTextRequest(options);
145
+ return { dryRun: true, kind: command.group, request: redactRequest(request) };
146
+ }
116
147
  const animation = createAnimation({
117
148
  json: Boolean(command.options.json),
118
149
  stream: deps.stderr,
@@ -123,11 +154,20 @@ async function handleGenerate(command, deps) {
123
154
  ? await generateImage(options)
124
155
  : command.group === "video"
125
156
  ? await generateVideo(options)
126
- : await generateAudio(options);
157
+ : command.group === "audio"
158
+ ? await generateAudio(options)
159
+ : await generateText(options);
160
+ if (command.group === "text") {
161
+ const text = extractText(response);
162
+ const output = await writeTextOutput(text, command.options.output);
163
+ return { kind: command.group, text, output, response };
164
+ }
127
165
  const artifacts = await persistArtifacts({
128
166
  kind: command.group,
129
167
  response,
130
168
  outDir: command.options.out ?? "flatkey-output",
169
+ output: command.options.output,
170
+ fetch: deps.fetch,
131
171
  });
132
172
  return { kind: command.group, artifacts, response };
133
173
  } finally {
@@ -135,6 +175,34 @@ async function handleGenerate(command, deps) {
135
175
  }
136
176
  }
137
177
 
178
+ function extractText(response) {
179
+ return response?.choices?.[0]?.message?.content
180
+ ?? response?.output_text
181
+ ?? response?.text
182
+ ?? "";
183
+ }
184
+
185
+ async function writeTextOutput(text, output) {
186
+ if (!output) return undefined;
187
+ const { mkdir, writeFile } = await import("node:fs/promises");
188
+ const { dirname } = await import("node:path");
189
+ await mkdir(dirname(output), { recursive: true });
190
+ await writeFile(output, text);
191
+ return output;
192
+ }
193
+
194
+ function redactRequest(request) {
195
+ return {
196
+ ...request,
197
+ headers: Object.fromEntries(
198
+ Object.entries(request.headers ?? {}).map(([key, value]) => [
199
+ key,
200
+ key.toLowerCase() === "authorization" ? "Bearer <redacted>" : value,
201
+ ]),
202
+ ),
203
+ };
204
+ }
205
+
138
206
  async function handleUtility(command, deps) {
139
207
  const { resolveApiKey } = await import("./config.js");
140
208
  const { getCredits, getStatus } = await import("./api.js");
@@ -176,3 +244,9 @@ function formatHuman(result) {
176
244
  if (typeof result === "string") return result;
177
245
  return JSON.stringify(result, null, 2);
178
246
  }
247
+
248
+ async function readPackageVersion() {
249
+ const { readFile } = await import("node:fs/promises");
250
+ const packageUrl = new URL("../package.json", import.meta.url);
251
+ return JSON.parse(await readFile(packageUrl, "utf8")).version;
252
+ }
package/src/help.js CHANGED
@@ -12,12 +12,13 @@ JSON mode:
12
12
  - ASCII animation and human logs are disabled.
13
13
 
14
14
  Commands:
15
- - flatkey image generate --prompt "<prompt>" --json [--model <model>] [--size 1024x1024] [--out flatkey-output]
16
- - flatkey video generate --prompt "<prompt>" --json [--model veo-3] [--duration 8] [--aspect 16:9]
17
- - flatkey audio generate --prompt "<prompt>" --json [--model tts-1] [--voice alloy] [--format mp3]
15
+ - flatkey image generate --prompt "<prompt>" --json [--model <model>] [--output image.png]
16
+ - flatkey video generate --prompt "<prompt>" --json [--model seedance2] [--output video.mp4]
17
+ - flatkey audio generate --prompt "<prompt>" --json [--model tts-1] [--output audio.mp3]
18
+ - flatkey text generate --prompt "<prompt>" --json [--model gpt-5.5] [--output text.txt]
18
19
  - flatkey credits --json
19
20
  - flatkey status --json
20
- - flatkey models --json [--type image|video|audio]
21
+ - flatkey models --json [--type image|video|audio|text]
21
22
  - flatkey help --ai
22
23
 
23
24
  Environment:
@@ -39,6 +40,7 @@ Commands:
39
40
  image generate --prompt <txt> Generate image
40
41
  video generate --prompt <txt> Generate video
41
42
  audio generate --prompt <txt> Generate audio
43
+ text generate --prompt <txt> Generate text
42
44
  credits Show remaining credits
43
45
  status Show usage/status
44
46
  models List available models
@@ -46,6 +48,7 @@ Commands:
46
48
 
47
49
  Global options:
48
50
  --json Print machine-readable JSON
51
+ --output, -o <file> Write generated output to a local file
49
52
  --base-url <url> Override Flatkey router URL
50
53
  `;
51
54
  }
package/src/models.js CHANGED
@@ -4,6 +4,8 @@ const BUNDLED_MODELS = [
4
4
  { id: "gpt-image-2", type: "image" },
5
5
  { id: "veo-3", type: "video" },
6
6
  { id: "veo-3-fast", type: "video" },
7
+ { id: "seedance2", type: "video" },
8
+ { id: "gpt-5.5", type: "text" },
7
9
  { id: "tts-1", type: "audio" },
8
10
  { id: "gpt-4o-mini-tts", type: "audio" },
9
11
  ];
@@ -35,5 +37,10 @@ function inferType(model) {
35
37
  if (model.capabilities?.includes("video")) return "video";
36
38
  if (model.capabilities?.includes("audio")) return "audio";
37
39
  if (model.capabilities?.includes("image")) return "image";
40
+ if (model.capabilities?.includes("text")) return "text";
41
+ if (/image|nano-banana/i.test(model.id)) return "image";
42
+ if (/seedance|veo|sora|video/i.test(model.id)) return "video";
43
+ if (/tts|audio|voice|speech/i.test(model.id)) return "audio";
44
+ if (model.object === "model") return "text";
38
45
  return undefined;
39
46
  }
package/test/api.test.js CHANGED
@@ -5,10 +5,14 @@ import {
5
5
  FlatkeyError,
6
6
  generateAudio,
7
7
  generateImage,
8
+ generateText,
8
9
  generateVideo,
9
10
  getCredits,
10
11
  getModels,
11
12
  getStatus,
13
+ planImageRequest,
14
+ planTextRequest,
15
+ planVideoRequest,
12
16
  } from "../src/api.js";
13
17
 
14
18
  function fetchRecorder(responseBody = { ok: true }) {
@@ -93,6 +97,39 @@ test("builds video generation request", async () => {
93
97
  });
94
98
  });
95
99
 
100
+ test("builds seedance2 video generation request", async () => {
101
+ const { fetch, calls } = fetchRecorder({ data: [] });
102
+
103
+ await generateVideo({
104
+ apiKey: "key",
105
+ baseUrl: "https://router.test",
106
+ model: "seedance2",
107
+ prompt: "newsroom b-roll",
108
+ fetch,
109
+ });
110
+
111
+ assert.equal(calls[0].url, "https://router.test/v1/videos/generations");
112
+ assert.equal(JSON.parse(calls[0].init.body).model, "seedance2");
113
+ });
114
+
115
+ test("builds text generation request for gpt-5.5", async () => {
116
+ const { fetch, calls } = fetchRecorder({ choices: [{ message: { content: "copy" } }] });
117
+
118
+ await generateText({
119
+ apiKey: "key",
120
+ baseUrl: "https://router.test",
121
+ model: "gpt-5.5",
122
+ prompt: "write headline",
123
+ fetch,
124
+ });
125
+
126
+ assert.equal(calls[0].url, "https://router.test/v1/chat/completions");
127
+ assert.deepEqual(JSON.parse(calls[0].init.body), {
128
+ model: "gpt-5.5",
129
+ messages: [{ role: "user", content: "write headline" }],
130
+ });
131
+ });
132
+
96
133
  test("builds audio generation request", async () => {
97
134
  const { fetch, calls } = fetchRecorder({ data: [] });
98
135
 
@@ -126,6 +163,43 @@ test("builds credits, status, and models requests", async () => {
126
163
  assert.equal(calls[1].url, "https://router.test/v1/status");
127
164
  assert.equal(calls[2].url, "https://router.test/v1/models");
128
165
  assert.equal(calls[0].init.headers.Authorization, "Bearer key");
166
+ assert.equal(calls[2].init.headers.Authorization, "Bearer key");
167
+ });
168
+
169
+ test("plans model list request from new-api relay route", async () => {
170
+ const { fetch, calls } = fetchRecorder({ object: "list", data: [{ id: "gpt-5.5" }] });
171
+
172
+ await getModels({ apiKey: "env-key", baseUrl: "https://router.flatkey.ai", fetch });
173
+
174
+ assert.equal(calls[0].url, "https://router.flatkey.ai/v1/models");
175
+ assert.equal(calls[0].init.method, "GET");
176
+ assert.equal(calls[0].init.headers.Authorization, "Bearer env-key");
177
+ });
178
+
179
+ test("plans dry-run requests for target media models", () => {
180
+ assert.equal(planImageRequest({
181
+ apiKey: "key",
182
+ model: "gpt-image-2",
183
+ prompt: "poster",
184
+ }).url, "https://router.flatkey.ai/v1/images/generations");
185
+
186
+ assert.match(planImageRequest({
187
+ apiKey: "key",
188
+ model: "nano-banana-pro-preview",
189
+ prompt: "poster",
190
+ }).url, /\/v1beta\/models\/nano-banana-pro-preview:generateContent\?key=key$/);
191
+
192
+ assert.equal(planVideoRequest({
193
+ apiKey: "key",
194
+ model: "seedance2",
195
+ prompt: "clip",
196
+ }).body.model, "seedance2");
197
+
198
+ assert.equal(planTextRequest({
199
+ apiKey: "key",
200
+ model: "gpt-5.5",
201
+ prompt: "headline",
202
+ }).body.model, "gpt-5.5");
129
203
  });
130
204
 
131
205
  test("throws FlatkeyError with API message on HTTP failure", async () => {
@@ -46,3 +46,63 @@ test("preserves remote url artifacts without downloading", async () => {
46
46
 
47
47
  assert.deepEqual(artifacts, [{ url: "https://cdn.test/audio.mp3" }]);
48
48
  });
49
+
50
+ test("saves first artifact to explicit output path", async () => {
51
+ const outDir = await mkdtemp(join(tmpdir(), "flatkey-artifacts-"));
52
+ const output = join(outDir, "custom.png");
53
+
54
+ const artifacts = await persistArtifacts({
55
+ kind: "image",
56
+ response: { data: [{ b64_json: Buffer.from("custom-bytes").toString("base64") }] },
57
+ output,
58
+ });
59
+
60
+ assert.deepEqual(artifacts, [{ path: output }]);
61
+ assert.equal(await readFile(output, "utf8"), "custom-bytes");
62
+ });
63
+
64
+ test("adds numeric suffix for multiple artifacts with explicit output path", async () => {
65
+ const outDir = await mkdtemp(join(tmpdir(), "flatkey-artifacts-"));
66
+ const output = join(outDir, "custom.png");
67
+
68
+ const artifacts = await persistArtifacts({
69
+ kind: "image",
70
+ response: {
71
+ data: [
72
+ { b64_json: Buffer.from("one").toString("base64") },
73
+ { b64_json: Buffer.from("two").toString("base64") },
74
+ ],
75
+ },
76
+ output,
77
+ });
78
+
79
+ assert.match(artifacts[0].path, /custom\.png$/);
80
+ assert.match(artifacts[1].path, /custom-02\.png$/);
81
+ assert.equal(await readFile(artifacts[1].path, "utf8"), "two");
82
+ });
83
+
84
+ test("downloads remote url artifact when explicit output path is set", async () => {
85
+ const outDir = await mkdtemp(join(tmpdir(), "flatkey-artifacts-"));
86
+ const output = join(outDir, "clip.mp4");
87
+ const calls = [];
88
+
89
+ const artifacts = await persistArtifacts({
90
+ kind: "video",
91
+ response: { data: [{ url: "https://cdn.test/clip.mp4" }] },
92
+ output,
93
+ fetch: async (url) => {
94
+ calls.push(url);
95
+ return {
96
+ ok: true,
97
+ status: 200,
98
+ async arrayBuffer() {
99
+ return Buffer.from("video-bytes");
100
+ },
101
+ };
102
+ },
103
+ });
104
+
105
+ assert.deepEqual(calls, ["https://cdn.test/clip.mp4"]);
106
+ assert.deepEqual(artifacts, [{ path: output }]);
107
+ assert.equal(await readFile(output, "utf8"), "video-bytes");
108
+ });
package/test/cli.test.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
 
4
- import { parseArgv } from "../src/cli.js";
4
+ import { parseArgv, runCommand } from "../src/cli.js";
5
5
 
6
6
  test("parses image generation with prompt and json mode", () => {
7
7
  const command = parseArgv(["image", "generate", "--prompt", "city at dawn", "--json"]);
@@ -28,6 +28,41 @@ test("parses credits with json mode", () => {
28
28
  });
29
29
  });
30
30
 
31
+ test("parses text generation with dry-run", () => {
32
+ const command = parseArgv([
33
+ "text",
34
+ "generate",
35
+ "--prompt",
36
+ "write lead",
37
+ "--model",
38
+ "gpt-5.5",
39
+ "--dry-run",
40
+ "--json",
41
+ ]);
42
+
43
+ assert.deepEqual(command, {
44
+ group: "text",
45
+ action: "generate",
46
+ options: {
47
+ prompt: "write lead",
48
+ model: "gpt-5.5",
49
+ dry_run: true,
50
+ json: true,
51
+ },
52
+ });
53
+ });
54
+
55
+ test("parses output aliases", () => {
56
+ assert.equal(
57
+ parseArgv(["image", "generate", "--prompt", "x", "--output", "out.png"]).options.output,
58
+ "out.png",
59
+ );
60
+ assert.equal(
61
+ parseArgv(["video", "generate", "--prompt", "x", "-o", "clip.mp4"]).options.output,
62
+ "clip.mp4",
63
+ );
64
+ });
65
+
31
66
  test("parses ai help", () => {
32
67
  const command = parseArgv(["help", "--ai"]);
33
68
 
@@ -46,3 +81,11 @@ test("rejects unknown commands", () => {
46
81
  /Unknown command: wat/,
47
82
  );
48
83
  });
84
+
85
+ test("version command matches package version", async () => {
86
+ const pkg = JSON.parse(await (await import("node:fs/promises")).readFile("package.json", "utf8"));
87
+
88
+ assert.deepEqual(await runCommand({ group: "version", options: {} }), {
89
+ version: pkg.version,
90
+ });
91
+ });
package/test/help.test.js CHANGED
@@ -11,6 +11,7 @@ test("ai help teaches agents setup and command usage", () => {
11
11
  assert.match(help, /flatkey image generate/);
12
12
  assert.match(help, /flatkey video generate/);
13
13
  assert.match(help, /flatkey audio generate/);
14
+ assert.match(help, /flatkey text generate/);
14
15
  assert.match(help, /flatkey credits --json/);
15
16
  assert.match(help, /flatkey status --json/);
16
17
  assert.match(help, /flatkey models --json/);
@@ -24,4 +25,5 @@ test("human help lists core commands", () => {
24
25
  assert.match(help, /Usage: flatkey/);
25
26
  assert.match(help, /image generate/);
26
27
  assert.match(help, /models/);
28
+ assert.match(help, /text generate/);
27
29
  });
@@ -1,6 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { createServer } from "node:http";
3
3
  import { spawn } from "node:child_process";
4
+ import { mkdtemp, readFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
4
7
  import { test } from "node:test";
5
8
 
6
9
  const BIN = new URL("../bin/flatkey.js", import.meta.url).pathname;
@@ -21,6 +24,8 @@ test("runs generation and utility commands in json mode", async (t) => {
21
24
  response.end(JSON.stringify({ data: [{ url: "https://cdn.test/video.mp4" }] }));
22
25
  } else if (request.url === "/v1/audio/generations") {
23
26
  response.end(JSON.stringify({ data: [{ url: "https://cdn.test/audio.mp3" }] }));
27
+ } else if (request.url === "/v1/chat/completions") {
28
+ response.end(JSON.stringify({ choices: [{ message: { content: "headline" } }] }));
24
29
  } else if (request.url === "/v1/credits") {
25
30
  response.end(JSON.stringify({ remaining: 42, used: 8 }));
26
31
  } else if (request.url === "/v1/status") {
@@ -41,6 +46,7 @@ test("runs generation and utility commands in json mode", async (t) => {
41
46
  const image = await runCli(["image", "generate", "--prompt", "poster", ...common]);
42
47
  const video = await runCli(["video", "generate", "--prompt", "clip", ...common]);
43
48
  const audio = await runCli(["audio", "generate", "--prompt", "voice", ...common]);
49
+ const text = await runCli(["text", "generate", "--prompt", "headline", ...common]);
44
50
  const credits = await runCli(["credits", ...common]);
45
51
  const status = await runCli(["status", ...common]);
46
52
  const models = await runCli(["models", ...common]);
@@ -48,6 +54,7 @@ test("runs generation and utility commands in json mode", async (t) => {
48
54
  assert.deepEqual(JSON.parse(image.stdout).artifacts, [{ url: "https://cdn.test/image.png" }]);
49
55
  assert.deepEqual(JSON.parse(video.stdout).artifacts, [{ url: "https://cdn.test/video.mp4" }]);
50
56
  assert.deepEqual(JSON.parse(audio.stdout).artifacts, [{ url: "https://cdn.test/audio.mp3" }]);
57
+ assert.equal(JSON.parse(text.stdout).text, "headline");
51
58
  assert.equal(JSON.parse(credits.stdout).remaining, 42);
52
59
  assert.equal(JSON.parse(status.stdout).status, "ok");
53
60
  assert.deepEqual(JSON.parse(models.stdout).models, [
@@ -57,6 +64,79 @@ test("runs generation and utility commands in json mode", async (t) => {
57
64
  assert.ok(requests.some((request) => request.url.startsWith("/v1beta/models/")));
58
65
  assert.ok(requests.some((request) => request.url === "/v1/videos/generations"));
59
66
  assert.ok(requests.some((request) => request.url === "/v1/audio/generations"));
67
+ assert.ok(requests.some((request) => request.url === "/v1/chat/completions"));
68
+ });
69
+
70
+ test("dry-run returns planned request without calling network", async () => {
71
+ const result = await runCli([
72
+ "image",
73
+ "generate",
74
+ "--prompt",
75
+ "poster",
76
+ "--model",
77
+ "gpt-image-2",
78
+ "--dry-run",
79
+ "--json",
80
+ ]);
81
+
82
+ const payload = JSON.parse(result.stdout);
83
+ assert.equal(payload.dryRun, true);
84
+ assert.equal(payload.kind, "image");
85
+ assert.equal(payload.request.url, "https://router.flatkey.ai/v1/images/generations");
86
+ assert.equal(payload.request.body.model, "gpt-image-2");
87
+ assert.equal(result.stderr, "");
88
+ });
89
+
90
+ test("generation commands write explicit output files", async (t) => {
91
+ const server = createServer((request, response) => {
92
+ request.on("data", () => {});
93
+ request.on("end", () => {
94
+ response.setHeader("content-type", "application/json");
95
+ if (request.url === "/v1/images/generations") {
96
+ response.end(JSON.stringify({
97
+ data: [{ b64_json: Buffer.from("image-file").toString("base64") }],
98
+ }));
99
+ } else if (request.url === "/v1/chat/completions") {
100
+ response.end(JSON.stringify({ choices: [{ message: { content: "text-file" } }] }));
101
+ } else {
102
+ response.statusCode = 404;
103
+ response.end(JSON.stringify({ error: { message: "not found" } }));
104
+ }
105
+ });
106
+ });
107
+ t.after(() => server.close());
108
+ await listen(server);
109
+ const baseUrl = `http://127.0.0.1:${server.address().port}`;
110
+ const dir = await mkdtemp(join(tmpdir(), "flatkey-output-"));
111
+ const imageOutput = join(dir, "poster.png");
112
+ const textOutput = join(dir, "headline.txt");
113
+
114
+ const common = ["--base-url", baseUrl, "--api-key", "test-key", "--json"];
115
+ const image = await runCli([
116
+ "image",
117
+ "generate",
118
+ "--model",
119
+ "gpt-image-2",
120
+ "--prompt",
121
+ "poster",
122
+ "--output",
123
+ imageOutput,
124
+ ...common,
125
+ ]);
126
+ const text = await runCli([
127
+ "text",
128
+ "generate",
129
+ "--prompt",
130
+ "headline",
131
+ "-o",
132
+ textOutput,
133
+ ...common,
134
+ ]);
135
+
136
+ assert.deepEqual(JSON.parse(image.stdout).artifacts, [{ path: imageOutput }]);
137
+ assert.equal(await readFile(imageOutput, "utf8"), "image-file");
138
+ assert.equal(JSON.parse(text.stdout).output, textOutput);
139
+ assert.equal(await readFile(textOutput, "utf8"), "text-file");
60
140
  });
61
141
 
62
142
  test("prints json errors to stderr in json mode", async () => {
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { test } from "node:test";
7
+
8
+ const BIN = new URL("../bin/flatkey.js", import.meta.url).pathname;
9
+ const LIVE = process.env.FLATKEY_LIVE_TESTS === "1";
10
+
11
+ const cases = [
12
+ {
13
+ name: "models list",
14
+ args: ["models", "--json"],
15
+ paid: false,
16
+ expect: (payload) => {
17
+ assert.ok(Array.isArray(payload.models));
18
+ assert.ok(payload.models.length > 0);
19
+ },
20
+ },
21
+ {
22
+ name: "image gpt",
23
+ args: ["image", "generate", "--model", "gpt-image-2", "--prompt", "one tiny red square icon", "--json"],
24
+ paid: true,
25
+ dryRun: false,
26
+ expect: (payload) => assert.equal(payload.kind, "image"),
27
+ },
28
+ {
29
+ name: "image nano",
30
+ args: ["image", "generate", "--model", "nano-banana-pro-preview", "--prompt", "one tiny blue square icon", "--json"],
31
+ paid: true,
32
+ dryRun: false,
33
+ expect: (payload) => assert.equal(payload.kind, "image"),
34
+ },
35
+ {
36
+ name: "video seedance2",
37
+ args: ["video", "generate", "--model", "seedance2", "--prompt", "one second newsroom establishing shot", "--duration", "1", "--json"],
38
+ paid: true,
39
+ dryRun: false,
40
+ expect: (payload) => assert.equal(payload.kind, "video"),
41
+ },
42
+ {
43
+ name: "text gpt-5.5",
44
+ args: ["text", "generate", "--model", "gpt-5.5", "--prompt", "Write a five word headline.", "--json"],
45
+ paid: true,
46
+ dryRun: false,
47
+ expect: (payload) => {
48
+ assert.equal(payload.kind, "text");
49
+ assert.equal(typeof payload.text, "string");
50
+ },
51
+ },
52
+ ];
53
+
54
+ for (const item of cases) {
55
+ test(`live flatkey ${item.name}${item.dryRun ? " dry-run" : ""}`, { skip: !LIVE }, async () => {
56
+ assert.ok(process.env.FLATKEY_API_KEY, "FLATKEY_API_KEY is required");
57
+ const outDir = await mkdtemp(join(tmpdir(), "flatkey-live-"));
58
+ const args = [...item.args, "--out", outDir];
59
+ if (item.dryRun) args.push("--dry-run");
60
+ const result = await runCli(args);
61
+ const payload = JSON.parse(result.stdout);
62
+ item.expect(payload);
63
+ });
64
+ }
65
+
66
+ function runCli(args) {
67
+ return new Promise((resolve, reject) => {
68
+ const child = spawn(process.execPath, [BIN, ...args], {
69
+ env: {
70
+ ...process.env,
71
+ FLATKEY_API_KEY: process.env.FLATKEY_API_KEY,
72
+ },
73
+ });
74
+ let stdout = "";
75
+ let stderr = "";
76
+ child.stdout.on("data", (chunk) => {
77
+ stdout += chunk;
78
+ });
79
+ child.stderr.on("data", (chunk) => {
80
+ stderr += chunk;
81
+ });
82
+ child.on("error", reject);
83
+ child.on("close", (code) => {
84
+ if (code !== 0) {
85
+ reject(new Error(`CLI exited ${code}\nstdout:${stdout}\nstderr:${stderr}`));
86
+ return;
87
+ }
88
+ resolve({ stdout, stderr });
89
+ });
90
+ });
91
+ }
@@ -7,7 +7,10 @@ test("returns bundled fallback models with source marker", () => {
7
7
  const models = getBundledModels();
8
8
 
9
9
  assert.ok(models.some((model) => model.id === "nano-banana-pro-preview"));
10
+ assert.ok(models.some((model) => model.id === "seedance2"));
11
+ assert.ok(models.some((model) => model.id === "gpt-5.5"));
10
12
  assert.ok(models.some((model) => model.type === "video"));
13
+ assert.ok(models.some((model) => model.type === "text"));
11
14
  assert.ok(models.some((model) => model.type === "audio"));
12
15
  assert.equal(models[0].source, "bundled");
13
16
  });
@@ -32,3 +35,22 @@ test("normalizes remote model arrays", () => {
32
35
  { id: "vid-x", type: "video", source: "remote" },
33
36
  ]);
34
37
  });
38
+
39
+ test("normalizes OpenAI model list response from /v1/models", () => {
40
+ const models = normalizeModels({
41
+ object: "list",
42
+ data: [
43
+ { id: "gpt-5.5", object: "model" },
44
+ { id: "seedance2", object: "model" },
45
+ { id: "gpt-image-2", object: "model" },
46
+ { id: "nano-banana-pro-preview", object: "model" },
47
+ ],
48
+ });
49
+
50
+ assert.deepEqual(models, [
51
+ { id: "gpt-5.5", type: "text", source: "remote" },
52
+ { id: "seedance2", type: "video", source: "remote" },
53
+ { id: "gpt-image-2", type: "image", source: "remote" },
54
+ { id: "nano-banana-pro-preview", type: "image", source: "remote" },
55
+ ]);
56
+ });