@flatkey-ai/cli 0.1.5 → 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/README.md +27 -1
- package/package.json +5 -1
- package/src/api.js +66 -5
- package/src/artifacts.js +9 -0
- package/src/cli.js +238 -3
- package/src/config.js +60 -3
- package/src/help.js +174 -4
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -50
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
- package/.github/pull_request_template.md +0 -13
- package/.github/workflows/npm-publish.yml +0 -36
- package/CONTRIBUTING.md +0 -37
- package/SECURITY.md +0 -13
- package/docs/superpowers/plans/2026-07-17-flatkey-cli.md +0 -90
- package/docs/superpowers/specs/2026-07-17-flatkey-cli-design.md +0 -209
- package/flatkey-output/audio-01.mp3 +0 -1
- package/release/deb/README.md +0 -16
- package/release/homebrew/flatkey.rb +0 -16
- package/release/msi/README.md +0 -15
- package/test/animation.test.js +0 -30
- package/test/api.test.js +0 -282
- package/test/artifacts.test.js +0 -108
- package/test/cli.test.js +0 -143
- package/test/config.test.js +0 -62
- package/test/help.test.js +0 -33
- package/test/integration.test.js +0 -274
- package/test/live-flatkey.test.js +0 -121
- package/test/models.test.js +0 -56
- package/test/package.test.js +0 -29
package/test/cli.test.js
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
|
|
4
|
-
import { parseArgv, runCommand } from "../src/cli.js";
|
|
5
|
-
|
|
6
|
-
test("parses image generation with prompt and json mode", () => {
|
|
7
|
-
const command = parseArgv(["image", "generate", "--prompt", "city at dawn", "--json"]);
|
|
8
|
-
|
|
9
|
-
assert.deepEqual(command, {
|
|
10
|
-
group: "image",
|
|
11
|
-
action: "generate",
|
|
12
|
-
options: {
|
|
13
|
-
prompt: "city at dawn",
|
|
14
|
-
json: true,
|
|
15
|
-
},
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("parses credits with json mode", () => {
|
|
20
|
-
const command = parseArgv(["credits", "--json"]);
|
|
21
|
-
|
|
22
|
-
assert.deepEqual(command, {
|
|
23
|
-
group: "credits",
|
|
24
|
-
action: undefined,
|
|
25
|
-
options: {
|
|
26
|
-
json: true,
|
|
27
|
-
},
|
|
28
|
-
});
|
|
29
|
-
});
|
|
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
|
-
|
|
66
|
-
test("parses audio voice generation controls", () => {
|
|
67
|
-
const command = parseArgv([
|
|
68
|
-
"audio",
|
|
69
|
-
"generate",
|
|
70
|
-
"--prompt",
|
|
71
|
-
"hello",
|
|
72
|
-
"--voice-id",
|
|
73
|
-
"voice-123",
|
|
74
|
-
"--stability",
|
|
75
|
-
"0.5",
|
|
76
|
-
"--similarity-boost",
|
|
77
|
-
"0.75",
|
|
78
|
-
"--style",
|
|
79
|
-
"0",
|
|
80
|
-
"-o",
|
|
81
|
-
"speech.mp3",
|
|
82
|
-
]);
|
|
83
|
-
|
|
84
|
-
assert.deepEqual(command, {
|
|
85
|
-
group: "audio",
|
|
86
|
-
action: "generate",
|
|
87
|
-
options: {
|
|
88
|
-
prompt: "hello",
|
|
89
|
-
voice_id: "voice-123",
|
|
90
|
-
stability: "0.5",
|
|
91
|
-
similarity_boost: "0.75",
|
|
92
|
-
style: "0",
|
|
93
|
-
output: "speech.mp3",
|
|
94
|
-
},
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("parses audio sfx, music, and voices actions", () => {
|
|
99
|
-
assert.equal(parseArgv(["audio", "sfx", "--prompt", "glass", "--duration", "3"]).action, "sfx");
|
|
100
|
-
assert.equal(parseArgv(["audio", "music", "--prompt", "piano", "--music-length-ms", "10000"]).action, "music");
|
|
101
|
-
assert.equal(parseArgv(["audio", "voices", "--json"]).action, "voices");
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("parses ai help", () => {
|
|
105
|
-
const command = parseArgv(["help", "--ai"]);
|
|
106
|
-
|
|
107
|
-
assert.deepEqual(command, {
|
|
108
|
-
group: "help",
|
|
109
|
-
action: undefined,
|
|
110
|
-
options: {
|
|
111
|
-
ai: true,
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("parses global version aliases", () => {
|
|
117
|
-
assert.deepEqual(parseArgv(["--version"]), {
|
|
118
|
-
group: "version",
|
|
119
|
-
action: undefined,
|
|
120
|
-
options: {},
|
|
121
|
-
});
|
|
122
|
-
assert.deepEqual(parseArgv(["-v"]), {
|
|
123
|
-
group: "version",
|
|
124
|
-
action: undefined,
|
|
125
|
-
options: {},
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("rejects unknown commands", () => {
|
|
130
|
-
assert.throws(
|
|
131
|
-
() => parseArgv(["wat"]),
|
|
132
|
-
/Unknown command: wat/,
|
|
133
|
-
);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("version command matches package version", async () => {
|
|
137
|
-
const pkg = JSON.parse(await (await import("node:fs/promises")).readFile("package.json", "utf8"));
|
|
138
|
-
|
|
139
|
-
assert.equal(await runCommand({ group: "version", options: {} }), pkg.version);
|
|
140
|
-
assert.deepEqual(await runCommand({ group: "version", options: { json: true } }), {
|
|
141
|
-
version: pkg.version,
|
|
142
|
-
});
|
|
143
|
-
});
|
package/test/config.test.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtemp, readFile, stat } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { test } from "node:test";
|
|
6
|
-
|
|
7
|
-
import { getConfigPath, resolveApiKey, writeConfig } from "../src/config.js";
|
|
8
|
-
|
|
9
|
-
test("resolves api key from explicit option first", async () => {
|
|
10
|
-
const configDir = await mkdtemp(join(tmpdir(), "flatkey-config-"));
|
|
11
|
-
await writeConfig({ apiKey: "saved-key", configDir });
|
|
12
|
-
|
|
13
|
-
const key = await resolveApiKey({
|
|
14
|
-
apiKey: "option-key",
|
|
15
|
-
env: { FLATKEY_API_KEY: "env-key" },
|
|
16
|
-
configDir,
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
assert.equal(key, "option-key");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("resolves api key from FLATKEY_API_KEY before saved config", async () => {
|
|
23
|
-
const configDir = await mkdtemp(join(tmpdir(), "flatkey-config-"));
|
|
24
|
-
await writeConfig({ apiKey: "saved-key", configDir });
|
|
25
|
-
|
|
26
|
-
const key = await resolveApiKey({
|
|
27
|
-
env: { FLATKEY_API_KEY: "env-key" },
|
|
28
|
-
configDir,
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
assert.equal(key, "env-key");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("resolves api key from saved config", async () => {
|
|
35
|
-
const configDir = await mkdtemp(join(tmpdir(), "flatkey-config-"));
|
|
36
|
-
await writeConfig({ apiKey: "saved-key", configDir });
|
|
37
|
-
|
|
38
|
-
const key = await resolveApiKey({ env: {}, configDir });
|
|
39
|
-
|
|
40
|
-
assert.equal(key, "saved-key");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("throws actionable error when api key is missing", async () => {
|
|
44
|
-
const configDir = await mkdtemp(join(tmpdir(), "flatkey-config-"));
|
|
45
|
-
|
|
46
|
-
await assert.rejects(
|
|
47
|
-
() => resolveApiKey({ env: {}, configDir }),
|
|
48
|
-
/Missing Flatkey API key.*flatkey onboard.*FLATKEY_API_KEY/s,
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("writes config json with api key", async () => {
|
|
53
|
-
const configDir = await mkdtemp(join(tmpdir(), "flatkey-config-"));
|
|
54
|
-
|
|
55
|
-
const configPath = await writeConfig({ apiKey: "written-key", configDir });
|
|
56
|
-
const saved = JSON.parse(await readFile(configPath, "utf8"));
|
|
57
|
-
const fileStat = await stat(configPath);
|
|
58
|
-
|
|
59
|
-
assert.equal(configPath, getConfigPath(configDir));
|
|
60
|
-
assert.equal(saved.apiKey, "written-key");
|
|
61
|
-
assert.ok((fileStat.mode & 0o777) === 0o600 || process.platform === "win32");
|
|
62
|
-
});
|
package/test/help.test.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
|
|
4
|
-
import { getAiHelp, getHumanHelp } from "../src/help.js";
|
|
5
|
-
|
|
6
|
-
test("ai help teaches agents setup and command usage", () => {
|
|
7
|
-
const help = getAiHelp();
|
|
8
|
-
|
|
9
|
-
assert.match(help, /FLATKEY_API_KEY/);
|
|
10
|
-
assert.match(help, /flatkey onboard --api-key/);
|
|
11
|
-
assert.match(help, /flatkey image generate/);
|
|
12
|
-
assert.match(help, /flatkey video generate/);
|
|
13
|
-
assert.match(help, /flatkey audio generate/);
|
|
14
|
-
assert.match(help, /flatkey audio sfx/);
|
|
15
|
-
assert.match(help, /flatkey audio music/);
|
|
16
|
-
assert.match(help, /flatkey audio voices/);
|
|
17
|
-
assert.match(help, /flatkey text generate/);
|
|
18
|
-
assert.match(help, /flatkey credits --json/);
|
|
19
|
-
assert.match(help, /flatkey status --json/);
|
|
20
|
-
assert.match(help, /flatkey models --json/);
|
|
21
|
-
assert.match(help, /JSON mode/);
|
|
22
|
-
assert.match(help, /Missing key/);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("human help lists core commands", () => {
|
|
26
|
-
const help = getHumanHelp();
|
|
27
|
-
|
|
28
|
-
assert.match(help, /Usage: flatkey/);
|
|
29
|
-
assert.match(help, /image generate/);
|
|
30
|
-
assert.match(help, /models/);
|
|
31
|
-
assert.match(help, /audio voices/);
|
|
32
|
-
assert.match(help, /text generate/);
|
|
33
|
-
});
|
package/test/integration.test.js
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { createServer } from "node:http";
|
|
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";
|
|
7
|
-
import { test } from "node:test";
|
|
8
|
-
|
|
9
|
-
const BIN = new URL("../bin/flatkey.js", import.meta.url).pathname;
|
|
10
|
-
|
|
11
|
-
test("runs generation and utility commands in json mode", async (t) => {
|
|
12
|
-
const requests = [];
|
|
13
|
-
const server = createServer((request, response) => {
|
|
14
|
-
let body = "";
|
|
15
|
-
request.on("data", (chunk) => {
|
|
16
|
-
body += chunk;
|
|
17
|
-
});
|
|
18
|
-
request.on("end", () => {
|
|
19
|
-
requests.push({ url: request.url, method: request.method, body });
|
|
20
|
-
if (request.url.startsWith("/v1beta/models/")) {
|
|
21
|
-
response.setHeader("content-type", "application/json");
|
|
22
|
-
response.end(JSON.stringify({ data: [{ url: "https://cdn.test/image.png" }] }));
|
|
23
|
-
} else if (request.url === "/v1/video/generations") {
|
|
24
|
-
response.setHeader("content-type", "application/json");
|
|
25
|
-
response.end(JSON.stringify({ data: [{ url: "https://cdn.test/video.mp4" }] }));
|
|
26
|
-
} else if (request.url === "/v1/text-to-speech/EXAVITQu4vr4xnSDxMaL") {
|
|
27
|
-
response.setHeader("content-type", "audio/mpeg");
|
|
28
|
-
response.end("audio-file");
|
|
29
|
-
} else if (request.url === "/v1/chat/completions") {
|
|
30
|
-
response.setHeader("content-type", "application/json");
|
|
31
|
-
response.end(JSON.stringify({ choices: [{ message: { content: "headline" } }] }));
|
|
32
|
-
} else if (request.url === "/v1/credits") {
|
|
33
|
-
response.setHeader("content-type", "application/json");
|
|
34
|
-
response.end(JSON.stringify({ remaining: 42, used: 8 }));
|
|
35
|
-
} else if (request.url === "/v1/status") {
|
|
36
|
-
response.setHeader("content-type", "application/json");
|
|
37
|
-
response.end(JSON.stringify({ status: "ok" }));
|
|
38
|
-
} else if (request.url === "/v1/available_models") {
|
|
39
|
-
response.setHeader("content-type", "application/json");
|
|
40
|
-
response.end(JSON.stringify({
|
|
41
|
-
success: true,
|
|
42
|
-
object: "list",
|
|
43
|
-
data: [{ id: "remote-image", object: "model", owned_by: "flatkey" }],
|
|
44
|
-
}));
|
|
45
|
-
} else if (request.url === "/v1/voices") {
|
|
46
|
-
response.setHeader("content-type", "application/json");
|
|
47
|
-
response.end(JSON.stringify({ voices: [{ voice_id: "voice-123", name: "Rachel" }] }));
|
|
48
|
-
} else {
|
|
49
|
-
response.setHeader("content-type", "application/json");
|
|
50
|
-
response.statusCode = 404;
|
|
51
|
-
response.end(JSON.stringify({ error: { message: "not found" } }));
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
t.after(() => server.close());
|
|
56
|
-
await listen(server);
|
|
57
|
-
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
58
|
-
|
|
59
|
-
const common = ["--base-url", baseUrl, "--api-key", "test-key", "--json"];
|
|
60
|
-
const image = await runCli(["image", "generate", "--prompt", "poster", ...common]);
|
|
61
|
-
const video = await runCli(["video", "generate", "--prompt", "clip", ...common]);
|
|
62
|
-
const audio = await runCli(["audio", "generate", "--prompt", "voice", ...common]);
|
|
63
|
-
const text = await runCli(["text", "generate", "--prompt", "headline", ...common]);
|
|
64
|
-
const credits = await runCli(["credits", ...common]);
|
|
65
|
-
const status = await runCli(["status", ...common]);
|
|
66
|
-
const models = await runCli(["models", ...common]);
|
|
67
|
-
const voices = await runCli(["audio", "voices", ...common]);
|
|
68
|
-
|
|
69
|
-
assert.deepEqual(JSON.parse(image.stdout).artifacts, [{ url: "https://cdn.test/image.png" }]);
|
|
70
|
-
assert.deepEqual(JSON.parse(video.stdout).artifacts, [{ url: "https://cdn.test/video.mp4" }]);
|
|
71
|
-
assert.equal(JSON.parse(audio.stdout).artifacts.length, 1);
|
|
72
|
-
assert.match(JSON.parse(audio.stdout).artifacts[0].path, /audio-01\.mp3$/);
|
|
73
|
-
assert.equal(JSON.parse(text.stdout).text, "headline");
|
|
74
|
-
assert.equal(JSON.parse(credits.stdout).remaining, 42);
|
|
75
|
-
assert.equal(JSON.parse(status.stdout).status, "ok");
|
|
76
|
-
assert.deepEqual(JSON.parse(models.stdout).models, [
|
|
77
|
-
{ id: "remote-image", type: "image", source: "remote" },
|
|
78
|
-
]);
|
|
79
|
-
assert.deepEqual(JSON.parse(voices.stdout).voices, [{ voice_id: "voice-123", name: "Rachel" }]);
|
|
80
|
-
assert.equal(image.stderr, "");
|
|
81
|
-
assert.ok(requests.some((request) => request.url.startsWith("/v1beta/models/")));
|
|
82
|
-
assert.ok(requests.some((request) => request.url === "/v1/video/generations"));
|
|
83
|
-
assert.ok(requests.some((request) => request.url === "/v1/text-to-speech/EXAVITQu4vr4xnSDxMaL"));
|
|
84
|
-
assert.ok(requests.some((request) => request.url === "/v1/chat/completions"));
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("dry-run returns planned request without calling network", async () => {
|
|
88
|
-
const result = await runCli([
|
|
89
|
-
"image",
|
|
90
|
-
"generate",
|
|
91
|
-
"--prompt",
|
|
92
|
-
"poster",
|
|
93
|
-
"--model",
|
|
94
|
-
"gpt-image-2",
|
|
95
|
-
"--dry-run",
|
|
96
|
-
"--json",
|
|
97
|
-
]);
|
|
98
|
-
|
|
99
|
-
const payload = JSON.parse(result.stdout);
|
|
100
|
-
assert.equal(payload.dryRun, true);
|
|
101
|
-
assert.equal(payload.kind, "image");
|
|
102
|
-
assert.equal(payload.request.url, "https://router.flatkey.ai/v1/images/generations");
|
|
103
|
-
assert.equal(payload.request.body.model, "gpt-image-2");
|
|
104
|
-
assert.equal(result.stderr, "");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("audio dry-run supports tts, sfx, and music routes", async () => {
|
|
108
|
-
const tts = await runCli([
|
|
109
|
-
"audio",
|
|
110
|
-
"generate",
|
|
111
|
-
"--prompt",
|
|
112
|
-
"hello",
|
|
113
|
-
"--voice-id",
|
|
114
|
-
"voice-123",
|
|
115
|
-
"--dry-run",
|
|
116
|
-
"--json",
|
|
117
|
-
]);
|
|
118
|
-
const sfx = await runCli([
|
|
119
|
-
"audio",
|
|
120
|
-
"sfx",
|
|
121
|
-
"--prompt",
|
|
122
|
-
"glass shattering",
|
|
123
|
-
"--duration",
|
|
124
|
-
"3",
|
|
125
|
-
"--dry-run",
|
|
126
|
-
"--json",
|
|
127
|
-
]);
|
|
128
|
-
const music = await runCli([
|
|
129
|
-
"audio",
|
|
130
|
-
"music",
|
|
131
|
-
"--prompt",
|
|
132
|
-
"calm ambient piano",
|
|
133
|
-
"--music-length-ms",
|
|
134
|
-
"10000",
|
|
135
|
-
"--dry-run",
|
|
136
|
-
"--json",
|
|
137
|
-
]);
|
|
138
|
-
|
|
139
|
-
assert.equal(JSON.parse(tts.stdout).request.url, "https://router.flatkey.ai/v1/text-to-speech/voice-123");
|
|
140
|
-
assert.equal(JSON.parse(sfx.stdout).request.url, "https://router.flatkey.ai/v1/sound-generation");
|
|
141
|
-
assert.equal(JSON.parse(sfx.stdout).request.body.duration_seconds, 3);
|
|
142
|
-
assert.equal(JSON.parse(music.stdout).request.url, "https://router.flatkey.ai/v1/music");
|
|
143
|
-
assert.equal(JSON.parse(music.stdout).request.body.music_length_ms, 10000);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("generation commands write explicit output files", async (t) => {
|
|
147
|
-
const server = createServer((request, response) => {
|
|
148
|
-
request.on("data", () => {});
|
|
149
|
-
request.on("end", () => {
|
|
150
|
-
response.setHeader("content-type", "application/json");
|
|
151
|
-
if (request.url === "/v1/images/generations") {
|
|
152
|
-
response.setHeader("content-type", "application/json");
|
|
153
|
-
response.end(JSON.stringify({
|
|
154
|
-
data: [{ b64_json: Buffer.from("image-file").toString("base64") }],
|
|
155
|
-
}));
|
|
156
|
-
} else if (request.url === "/v1/text-to-speech/EXAVITQu4vr4xnSDxMaL") {
|
|
157
|
-
response.setHeader("content-type", "audio/mpeg");
|
|
158
|
-
response.end("audio-file");
|
|
159
|
-
} else if (request.url === "/v1/chat/completions") {
|
|
160
|
-
response.setHeader("content-type", "application/json");
|
|
161
|
-
response.end(JSON.stringify({ choices: [{ message: { content: "text-file" } }] }));
|
|
162
|
-
} else {
|
|
163
|
-
response.setHeader("content-type", "application/json");
|
|
164
|
-
response.statusCode = 404;
|
|
165
|
-
response.end(JSON.stringify({ error: { message: "not found" } }));
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
t.after(() => server.close());
|
|
170
|
-
await listen(server);
|
|
171
|
-
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
172
|
-
const dir = await mkdtemp(join(tmpdir(), "flatkey-output-"));
|
|
173
|
-
const imageOutput = join(dir, "poster.png");
|
|
174
|
-
const audioOutput = join(dir, "speech.mp3");
|
|
175
|
-
const textOutput = join(dir, "headline.txt");
|
|
176
|
-
|
|
177
|
-
const common = ["--base-url", baseUrl, "--api-key", "test-key", "--json"];
|
|
178
|
-
const image = await runCli([
|
|
179
|
-
"image",
|
|
180
|
-
"generate",
|
|
181
|
-
"--model",
|
|
182
|
-
"gpt-image-2",
|
|
183
|
-
"--prompt",
|
|
184
|
-
"poster",
|
|
185
|
-
"--output",
|
|
186
|
-
imageOutput,
|
|
187
|
-
...common,
|
|
188
|
-
]);
|
|
189
|
-
const text = await runCli([
|
|
190
|
-
"text",
|
|
191
|
-
"generate",
|
|
192
|
-
"--prompt",
|
|
193
|
-
"headline",
|
|
194
|
-
"-o",
|
|
195
|
-
textOutput,
|
|
196
|
-
...common,
|
|
197
|
-
]);
|
|
198
|
-
const audio = await runCli([
|
|
199
|
-
"audio",
|
|
200
|
-
"generate",
|
|
201
|
-
"--prompt",
|
|
202
|
-
"hello",
|
|
203
|
-
"-o",
|
|
204
|
-
audioOutput,
|
|
205
|
-
...common,
|
|
206
|
-
]);
|
|
207
|
-
|
|
208
|
-
assert.deepEqual(JSON.parse(image.stdout).artifacts, [{ path: imageOutput }]);
|
|
209
|
-
assert.equal(await readFile(imageOutput, "utf8"), "image-file");
|
|
210
|
-
assert.deepEqual(JSON.parse(audio.stdout).artifacts, [{ path: audioOutput }]);
|
|
211
|
-
assert.equal(await readFile(audioOutput, "utf8"), "audio-file");
|
|
212
|
-
assert.equal(JSON.parse(text.stdout).output, textOutput);
|
|
213
|
-
assert.equal(await readFile(textOutput, "utf8"), "text-file");
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
test("prints json errors to stderr in json mode", async () => {
|
|
217
|
-
const result = await runCliAllowFailure(["credits", "--json"]);
|
|
218
|
-
|
|
219
|
-
assert.equal(result.code, 1);
|
|
220
|
-
assert.equal(result.stdout, "");
|
|
221
|
-
assert.deepEqual(JSON.parse(result.stderr), {
|
|
222
|
-
error: {
|
|
223
|
-
message: "Missing Flatkey API key. Run `flatkey onboard --api-key <key>` or set FLATKEY_API_KEY.",
|
|
224
|
-
},
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
function listen(server) {
|
|
229
|
-
return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function runCli(args) {
|
|
233
|
-
return new Promise((resolve, reject) => {
|
|
234
|
-
const child = spawn(process.execPath, [BIN, ...args], {
|
|
235
|
-
env: { ...process.env, FLATKEY_API_KEY: "" },
|
|
236
|
-
});
|
|
237
|
-
let stdout = "";
|
|
238
|
-
let stderr = "";
|
|
239
|
-
child.stdout.on("data", (chunk) => {
|
|
240
|
-
stdout += chunk;
|
|
241
|
-
});
|
|
242
|
-
child.stderr.on("data", (chunk) => {
|
|
243
|
-
stderr += chunk;
|
|
244
|
-
});
|
|
245
|
-
child.on("error", reject);
|
|
246
|
-
child.on("close", (code) => {
|
|
247
|
-
if (code !== 0) {
|
|
248
|
-
reject(new Error(`CLI exited ${code}\nstdout:${stdout}\nstderr:${stderr}`));
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
resolve({ stdout, stderr });
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function runCliAllowFailure(args) {
|
|
257
|
-
return new Promise((resolve, reject) => {
|
|
258
|
-
const child = spawn(process.execPath, [BIN, ...args], {
|
|
259
|
-
env: { ...process.env, FLATKEY_API_KEY: "" },
|
|
260
|
-
});
|
|
261
|
-
let stdout = "";
|
|
262
|
-
let stderr = "";
|
|
263
|
-
child.stdout.on("data", (chunk) => {
|
|
264
|
-
stdout += chunk;
|
|
265
|
-
});
|
|
266
|
-
child.stderr.on("data", (chunk) => {
|
|
267
|
-
stderr += chunk;
|
|
268
|
-
});
|
|
269
|
-
child.on("error", reject);
|
|
270
|
-
child.on("close", (code) => {
|
|
271
|
-
resolve({ code, stdout, stderr });
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
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: "audio voices",
|
|
23
|
-
args: ["audio", "voices", "--json"],
|
|
24
|
-
paid: false,
|
|
25
|
-
expect: (payload) => {
|
|
26
|
-
assert.ok(Array.isArray(payload.voices));
|
|
27
|
-
assert.ok(payload.voices.length > 0);
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
name: "audio tts",
|
|
32
|
-
args: ["audio", "generate", "--prompt", "flatkey voice test", "--json"],
|
|
33
|
-
paid: true,
|
|
34
|
-
dryRun: true,
|
|
35
|
-
expect: (payload) => assert.equal(payload.request.url, "https://router.flatkey.ai/v1/text-to-speech/EXAVITQu4vr4xnSDxMaL"),
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
name: "audio sfx",
|
|
39
|
-
args: ["audio", "sfx", "--prompt", "glass shattering", "--duration", "3", "--json"],
|
|
40
|
-
paid: true,
|
|
41
|
-
dryRun: true,
|
|
42
|
-
expect: (payload) => assert.equal(payload.request.url, "https://router.flatkey.ai/v1/sound-generation"),
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
name: "audio music",
|
|
46
|
-
args: ["audio", "music", "--prompt", "calm ambient piano", "--music-length-ms", "10000", "--json"],
|
|
47
|
-
paid: true,
|
|
48
|
-
dryRun: true,
|
|
49
|
-
expect: (payload) => assert.equal(payload.request.url, "https://router.flatkey.ai/v1/music"),
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
name: "image gpt",
|
|
53
|
-
args: ["image", "generate", "--model", "gpt-image-2", "--prompt", "one tiny red square icon", "--json"],
|
|
54
|
-
paid: true,
|
|
55
|
-
dryRun: false,
|
|
56
|
-
expect: (payload) => assert.equal(payload.kind, "image"),
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: "image nano",
|
|
60
|
-
args: ["image", "generate", "--model", "nano-banana-pro-preview", "--prompt", "one tiny blue square icon", "--json"],
|
|
61
|
-
paid: true,
|
|
62
|
-
dryRun: false,
|
|
63
|
-
expect: (payload) => assert.equal(payload.kind, "image"),
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
name: "video seedance2",
|
|
67
|
-
args: ["video", "generate", "--model", "seedance2", "--prompt", "one second newsroom establishing shot", "--duration", "1", "--json"],
|
|
68
|
-
paid: true,
|
|
69
|
-
dryRun: false,
|
|
70
|
-
expect: (payload) => assert.equal(payload.kind, "video"),
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
name: "text gpt-5.5",
|
|
74
|
-
args: ["text", "generate", "--model", "gpt-5.5", "--prompt", "Write a five word headline.", "--json"],
|
|
75
|
-
paid: true,
|
|
76
|
-
dryRun: false,
|
|
77
|
-
expect: (payload) => {
|
|
78
|
-
assert.equal(payload.kind, "text");
|
|
79
|
-
assert.equal(typeof payload.text, "string");
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
];
|
|
83
|
-
|
|
84
|
-
for (const item of cases) {
|
|
85
|
-
test(`live flatkey ${item.name}${item.dryRun ? " dry-run" : ""}`, { skip: !LIVE }, async () => {
|
|
86
|
-
assert.ok(process.env.FLATKEY_API_KEY, "FLATKEY_API_KEY is required");
|
|
87
|
-
const outDir = await mkdtemp(join(tmpdir(), "flatkey-live-"));
|
|
88
|
-
const args = [...item.args, "--out", outDir];
|
|
89
|
-
if (item.dryRun) args.push("--dry-run");
|
|
90
|
-
const result = await runCli(args);
|
|
91
|
-
const payload = JSON.parse(result.stdout);
|
|
92
|
-
item.expect(payload);
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function runCli(args) {
|
|
97
|
-
return new Promise((resolve, reject) => {
|
|
98
|
-
const child = spawn(process.execPath, [BIN, ...args], {
|
|
99
|
-
env: {
|
|
100
|
-
...process.env,
|
|
101
|
-
FLATKEY_API_KEY: process.env.FLATKEY_API_KEY,
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
let stdout = "";
|
|
105
|
-
let stderr = "";
|
|
106
|
-
child.stdout.on("data", (chunk) => {
|
|
107
|
-
stdout += chunk;
|
|
108
|
-
});
|
|
109
|
-
child.stderr.on("data", (chunk) => {
|
|
110
|
-
stderr += chunk;
|
|
111
|
-
});
|
|
112
|
-
child.on("error", reject);
|
|
113
|
-
child.on("close", (code) => {
|
|
114
|
-
if (code !== 0) {
|
|
115
|
-
reject(new Error(`CLI exited ${code}\nstdout:${stdout}\nstderr:${stderr}`));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
resolve({ stdout, stderr });
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
}
|
package/test/models.test.js
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
|
|
4
|
-
import { getBundledModels, normalizeModels } from "../src/models.js";
|
|
5
|
-
|
|
6
|
-
test("returns bundled fallback models with source marker", () => {
|
|
7
|
-
const models = getBundledModels();
|
|
8
|
-
|
|
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"));
|
|
12
|
-
assert.ok(models.some((model) => model.type === "video"));
|
|
13
|
-
assert.ok(models.some((model) => model.type === "text"));
|
|
14
|
-
assert.ok(models.some((model) => model.type === "audio"));
|
|
15
|
-
assert.equal(models[0].source, "bundled");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test("filters bundled models by type", () => {
|
|
19
|
-
const models = getBundledModels("image");
|
|
20
|
-
|
|
21
|
-
assert.ok(models.length > 0);
|
|
22
|
-
assert.ok(models.every((model) => model.type === "image"));
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("normalizes remote model arrays", () => {
|
|
26
|
-
const models = normalizeModels({
|
|
27
|
-
data: [
|
|
28
|
-
{ id: "img-x", type: "image" },
|
|
29
|
-
{ id: "vid-x", capabilities: ["video"] },
|
|
30
|
-
],
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
assert.deepEqual(models, [
|
|
34
|
-
{ id: "img-x", type: "image", source: "remote" },
|
|
35
|
-
{ id: "vid-x", type: "video", source: "remote" },
|
|
36
|
-
]);
|
|
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
|
-
});
|