@flatkey-ai/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +36 -0
- package/README.md +51 -0
- package/bin/flatkey.js +11 -0
- package/docs/superpowers/plans/2026-07-17-flatkey-cli.md +90 -0
- package/docs/superpowers/specs/2026-07-17-flatkey-cli-design.md +209 -0
- package/package.json +16 -0
- package/release/deb/README.md +16 -0
- package/release/homebrew/flatkey.rb +16 -0
- package/release/msi/README.md +15 -0
- package/src/animation.js +40 -0
- package/src/api.js +129 -0
- package/src/artifacts.js +86 -0
- package/src/cli.js +178 -0
- package/src/config.js +54 -0
- package/src/help.js +51 -0
- package/src/models.js +39 -0
- package/test/animation.test.js +30 -0
- package/test/api.test.js +144 -0
- package/test/artifacts.test.js +48 -0
- package/test/cli.test.js +48 -0
- package/test/config.test.js +62 -0
- package/test/help.test.js +27 -0
- package/test/integration.test.js +120 -0
- package/test/models.test.js +34 -0
- package/test/package.test.js +29 -0
package/src/artifacts.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_EXTENSIONS = {
|
|
5
|
+
audio: "mp3",
|
|
6
|
+
image: "png",
|
|
7
|
+
video: "mp4",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const MIME_EXTENSIONS = {
|
|
11
|
+
"audio/aac": "aac",
|
|
12
|
+
"audio/flac": "flac",
|
|
13
|
+
"audio/mpeg": "mp3",
|
|
14
|
+
"audio/mp3": "mp3",
|
|
15
|
+
"audio/wav": "wav",
|
|
16
|
+
"image/jpeg": "jpg",
|
|
17
|
+
"image/png": "png",
|
|
18
|
+
"video/mp4": "mp4",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function persistArtifacts({ kind, response, outDir = "flatkey-output" }) {
|
|
22
|
+
const items = extractItems(response);
|
|
23
|
+
const artifacts = [];
|
|
24
|
+
await mkdir(outDir, { recursive: true });
|
|
25
|
+
|
|
26
|
+
for (const [index, item] of items.entries()) {
|
|
27
|
+
const artifact = await persistItem({ kind, item, outDir, index });
|
|
28
|
+
if (artifact) artifacts.push(artifact);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return artifacts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function persistItem({ kind, item, outDir, index }) {
|
|
35
|
+
const dataUrl = getString(item, ["url", "data_url", "dataUrl"]);
|
|
36
|
+
if (dataUrl?.startsWith("data:")) {
|
|
37
|
+
const parsed = parseDataUrl(dataUrl);
|
|
38
|
+
const path = artifactPath({ kind, outDir, index, extension: parsed.extension });
|
|
39
|
+
await writeFile(path, parsed.buffer);
|
|
40
|
+
return { path };
|
|
41
|
+
}
|
|
42
|
+
if (dataUrl?.startsWith("http://") || dataUrl?.startsWith("https://")) {
|
|
43
|
+
return { url: dataUrl };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const base64 = getString(item, ["b64_json", "base64", "data"]);
|
|
47
|
+
if (base64) {
|
|
48
|
+
const path = artifactPath({ kind, outDir, index, extension: DEFAULT_EXTENSIONS[kind] });
|
|
49
|
+
await writeFile(path, Buffer.from(base64, "base64"));
|
|
50
|
+
return { path };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractItems(response) {
|
|
57
|
+
if (Array.isArray(response?.data)) return response.data;
|
|
58
|
+
if (Array.isArray(response?.artifacts)) return response.artifacts;
|
|
59
|
+
if (Array.isArray(response?.candidates)) {
|
|
60
|
+
return response.candidates.flatMap((candidate) => candidate.content?.parts ?? []);
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getString(item, keys) {
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
if (typeof item?.[key] === "string") return item[key];
|
|
68
|
+
if (typeof item?.inlineData?.[key] === "string") return item.inlineData[key];
|
|
69
|
+
if (typeof item?.inline_data?.[key] === "string") return item.inline_data[key];
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseDataUrl(value) {
|
|
75
|
+
const match = /^data:([^;,]+);base64,(.+)$/s.exec(value);
|
|
76
|
+
if (!match) throw new Error("Unsupported data URL artifact.");
|
|
77
|
+
return {
|
|
78
|
+
extension: MIME_EXTENSIONS[match[1]] ?? "bin",
|
|
79
|
+
buffer: Buffer.from(match[2], "base64"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function artifactPath({ kind, outDir, index, extension }) {
|
|
84
|
+
const number = String(index + 1).padStart(2, "0");
|
|
85
|
+
return join(outDir, `${kind}-${number}.${extension}`);
|
|
86
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
const COMMANDS = new Set([
|
|
2
|
+
"audio",
|
|
3
|
+
"credits",
|
|
4
|
+
"help",
|
|
5
|
+
"image",
|
|
6
|
+
"models",
|
|
7
|
+
"onboard",
|
|
8
|
+
"status",
|
|
9
|
+
"version",
|
|
10
|
+
"video",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const GROUP_ACTIONS = new Set(["audio", "image", "video"]);
|
|
14
|
+
|
|
15
|
+
export function parseArgv(argv) {
|
|
16
|
+
const [group, maybeAction, ...rest] = argv;
|
|
17
|
+
if (!group) {
|
|
18
|
+
return { group: "help", action: undefined, options: {} };
|
|
19
|
+
}
|
|
20
|
+
if (!COMMANDS.has(group)) {
|
|
21
|
+
throw new Error(`Unknown command: ${group}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const hasAction = GROUP_ACTIONS.has(group);
|
|
25
|
+
const action = hasAction ? maybeAction : undefined;
|
|
26
|
+
const optionTokens = hasAction ? rest : argv.slice(1);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
group,
|
|
30
|
+
action,
|
|
31
|
+
options: parseOptions(optionTokens),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseOptions(tokens) {
|
|
36
|
+
const options = {};
|
|
37
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
38
|
+
const token = tokens[index];
|
|
39
|
+
if (!token.startsWith("--")) {
|
|
40
|
+
throw new Error(`Unexpected argument: ${token}`);
|
|
41
|
+
}
|
|
42
|
+
const name = token.slice(2).replaceAll("-", "_");
|
|
43
|
+
const next = tokens[index + 1];
|
|
44
|
+
if (!next || next.startsWith("--")) {
|
|
45
|
+
options[name] = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
options[name] = next;
|
|
49
|
+
index += 1;
|
|
50
|
+
}
|
|
51
|
+
return options;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function main(argv) {
|
|
55
|
+
const command = parseArgv(argv);
|
|
56
|
+
if (command.group === "onboard") {
|
|
57
|
+
const { writeConfig } = await import("./config.js");
|
|
58
|
+
const configPath = await writeConfig({ apiKey: command.options.api_key });
|
|
59
|
+
process.stdout.write(`Saved Flatkey config: ${configPath}\n`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await runCommand(command);
|
|
64
|
+
if (result !== undefined) {
|
|
65
|
+
process.stdout.write(
|
|
66
|
+
command.options.json ? `${JSON.stringify(result)}\n` : `${formatHuman(result)}\n`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runCommand(command, deps = {}) {
|
|
72
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
73
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
74
|
+
|
|
75
|
+
if (command.group === "help") {
|
|
76
|
+
const { getAiHelp, getHumanHelp } = await import("./help.js");
|
|
77
|
+
return command.options.ai ? getAiHelp() : getHumanHelp();
|
|
78
|
+
}
|
|
79
|
+
if (command.group === "version") {
|
|
80
|
+
return { version: "0.1.0" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (command.group === "models") {
|
|
84
|
+
return handleModels(command, deps);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (command.group === "credits" || command.group === "status") {
|
|
88
|
+
return handleUtility(command, deps);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (["image", "video", "audio"].includes(command.group)) {
|
|
92
|
+
if (command.action !== "generate") {
|
|
93
|
+
throw new Error(`Unknown action for ${command.group}: ${command.action}`);
|
|
94
|
+
}
|
|
95
|
+
return handleGenerate(command, { ...deps, stdout, stderr });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw new Error(`Unknown command: ${command.group}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleGenerate(command, deps) {
|
|
102
|
+
const { resolveApiKey } = await import("./config.js");
|
|
103
|
+
const { generateAudio, generateImage, generateVideo } = await import("./api.js");
|
|
104
|
+
const { persistArtifacts } = await import("./artifacts.js");
|
|
105
|
+
const { createAnimation } = await import("./animation.js");
|
|
106
|
+
const apiKey = await resolveApiKey({
|
|
107
|
+
apiKey: command.options.api_key,
|
|
108
|
+
env: deps.env ?? process.env,
|
|
109
|
+
});
|
|
110
|
+
const options = {
|
|
111
|
+
...command.options,
|
|
112
|
+
apiKey,
|
|
113
|
+
baseUrl: command.options.base_url,
|
|
114
|
+
fetch: deps.fetch,
|
|
115
|
+
};
|
|
116
|
+
const animation = createAnimation({
|
|
117
|
+
json: Boolean(command.options.json),
|
|
118
|
+
stream: deps.stderr,
|
|
119
|
+
});
|
|
120
|
+
animation.start(command.group);
|
|
121
|
+
try {
|
|
122
|
+
const response = command.group === "image"
|
|
123
|
+
? await generateImage(options)
|
|
124
|
+
: command.group === "video"
|
|
125
|
+
? await generateVideo(options)
|
|
126
|
+
: await generateAudio(options);
|
|
127
|
+
const artifacts = await persistArtifacts({
|
|
128
|
+
kind: command.group,
|
|
129
|
+
response,
|
|
130
|
+
outDir: command.options.out ?? "flatkey-output",
|
|
131
|
+
});
|
|
132
|
+
return { kind: command.group, artifacts, response };
|
|
133
|
+
} finally {
|
|
134
|
+
animation.stop();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function handleUtility(command, deps) {
|
|
139
|
+
const { resolveApiKey } = await import("./config.js");
|
|
140
|
+
const { getCredits, getStatus } = await import("./api.js");
|
|
141
|
+
const apiKey = await resolveApiKey({
|
|
142
|
+
apiKey: command.options.api_key,
|
|
143
|
+
env: deps.env ?? process.env,
|
|
144
|
+
});
|
|
145
|
+
const options = {
|
|
146
|
+
apiKey,
|
|
147
|
+
baseUrl: command.options.base_url,
|
|
148
|
+
fetch: deps.fetch,
|
|
149
|
+
};
|
|
150
|
+
return command.group === "credits" ? getCredits(options) : getStatus(options);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function handleModels(command, deps) {
|
|
154
|
+
const { resolveApiKey } = await import("./config.js");
|
|
155
|
+
const { getModels } = await import("./api.js");
|
|
156
|
+
const { getBundledModels, normalizeModels } = await import("./models.js");
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const apiKey = await resolveApiKey({
|
|
160
|
+
apiKey: command.options.api_key,
|
|
161
|
+
env: deps.env ?? process.env,
|
|
162
|
+
});
|
|
163
|
+
const response = await getModels({
|
|
164
|
+
apiKey,
|
|
165
|
+
baseUrl: command.options.base_url,
|
|
166
|
+
fetch: deps.fetch,
|
|
167
|
+
});
|
|
168
|
+
const models = normalizeModels(response, command.options.type);
|
|
169
|
+
return { models: models.length ? models : getBundledModels(command.options.type) };
|
|
170
|
+
} catch {
|
|
171
|
+
return { models: getBundledModels(command.options.type) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatHuman(result) {
|
|
176
|
+
if (typeof result === "string") return result;
|
|
177
|
+
return JSON.stringify(result, null, 2);
|
|
178
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function getDefaultConfigDir() {
|
|
6
|
+
return join(homedir(), ".config", "flatkey");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getConfigPath(configDir = getDefaultConfigDir()) {
|
|
10
|
+
return join(configDir, "config.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function resolveApiKey({
|
|
14
|
+
apiKey,
|
|
15
|
+
env = process.env,
|
|
16
|
+
configDir = getDefaultConfigDir(),
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (apiKey) return apiKey;
|
|
19
|
+
if (env.FLATKEY_API_KEY) return env.FLATKEY_API_KEY;
|
|
20
|
+
|
|
21
|
+
const saved = await readSavedConfig(configDir);
|
|
22
|
+
if (saved?.apiKey) return saved.apiKey;
|
|
23
|
+
|
|
24
|
+
throw new Error(
|
|
25
|
+
"Missing Flatkey API key. Run `flatkey onboard --api-key <key>` or set FLATKEY_API_KEY.",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function writeConfig({ apiKey, configDir = getDefaultConfigDir() }) {
|
|
30
|
+
if (!apiKey) {
|
|
31
|
+
throw new Error("Missing --api-key value.");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await mkdir(configDir, { recursive: true });
|
|
35
|
+
const configPath = getConfigPath(configDir);
|
|
36
|
+
await writeFile(configPath, `${JSON.stringify({ apiKey }, null, 2)}\n`, {
|
|
37
|
+
mode: 0o600,
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
await chmod(configPath, 0o600);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (process.platform !== "win32") throw error;
|
|
43
|
+
}
|
|
44
|
+
return configPath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readSavedConfig(configDir) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(await readFile(getConfigPath(configDir), "utf8"));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.code === "ENOENT") return undefined;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/help.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function getAiHelp() {
|
|
2
|
+
return `Flatkey CLI protocol for agents
|
|
3
|
+
|
|
4
|
+
Setup:
|
|
5
|
+
- Prefer env: FLATKEY_API_KEY=<key>
|
|
6
|
+
- Or save key: flatkey onboard --api-key <key>
|
|
7
|
+
- Use --json for machine-readable output.
|
|
8
|
+
|
|
9
|
+
JSON mode:
|
|
10
|
+
- stdout contains only JSON.
|
|
11
|
+
- stderr contains JSON errors on failure.
|
|
12
|
+
- ASCII animation and human logs are disabled.
|
|
13
|
+
|
|
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]
|
|
18
|
+
- flatkey credits --json
|
|
19
|
+
- flatkey status --json
|
|
20
|
+
- flatkey models --json [--type image|video|audio]
|
|
21
|
+
- flatkey help --ai
|
|
22
|
+
|
|
23
|
+
Environment:
|
|
24
|
+
- FLATKEY_API_KEY: Flatkey API key.
|
|
25
|
+
- Default router: https://router.flatkey.ai
|
|
26
|
+
|
|
27
|
+
Recovery:
|
|
28
|
+
- Missing key: run flatkey onboard --api-key <key> or set FLATKEY_API_KEY.
|
|
29
|
+
- Unknown model: run flatkey models --json, then retry with a listed model id.
|
|
30
|
+
- API failure: inspect stderr JSON or HTTP message, then retry only after fixing request or credits.
|
|
31
|
+
`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getHumanHelp() {
|
|
35
|
+
return `Usage: flatkey <command> [options]
|
|
36
|
+
|
|
37
|
+
Commands:
|
|
38
|
+
onboard --api-key <key> Save Flatkey API key
|
|
39
|
+
image generate --prompt <txt> Generate image
|
|
40
|
+
video generate --prompt <txt> Generate video
|
|
41
|
+
audio generate --prompt <txt> Generate audio
|
|
42
|
+
credits Show remaining credits
|
|
43
|
+
status Show usage/status
|
|
44
|
+
models List available models
|
|
45
|
+
help --ai Print agent-focused usage guide
|
|
46
|
+
|
|
47
|
+
Global options:
|
|
48
|
+
--json Print machine-readable JSON
|
|
49
|
+
--base-url <url> Override Flatkey router URL
|
|
50
|
+
`;
|
|
51
|
+
}
|
package/src/models.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const BUNDLED_MODELS = [
|
|
2
|
+
{ id: "nano-banana-pro-preview", type: "image" },
|
|
3
|
+
{ id: "nano-banana-flash", type: "image" },
|
|
4
|
+
{ id: "gpt-image-2", type: "image" },
|
|
5
|
+
{ id: "veo-3", type: "video" },
|
|
6
|
+
{ id: "veo-3-fast", type: "video" },
|
|
7
|
+
{ id: "tts-1", type: "audio" },
|
|
8
|
+
{ id: "gpt-4o-mini-tts", type: "audio" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export function getBundledModels(type) {
|
|
12
|
+
return BUNDLED_MODELS
|
|
13
|
+
.filter((model) => !type || model.type === type)
|
|
14
|
+
.map((model) => ({ ...model, source: "bundled" }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeModels(response, type) {
|
|
18
|
+
const rawModels = Array.isArray(response?.data)
|
|
19
|
+
? response.data
|
|
20
|
+
: Array.isArray(response?.models)
|
|
21
|
+
? response.models
|
|
22
|
+
: [];
|
|
23
|
+
|
|
24
|
+
return rawModels
|
|
25
|
+
.map((model) => ({
|
|
26
|
+
id: model.id,
|
|
27
|
+
type: model.type ?? inferType(model),
|
|
28
|
+
source: "remote",
|
|
29
|
+
}))
|
|
30
|
+
.filter((model) => model.id && model.type)
|
|
31
|
+
.filter((model) => !type || model.type === type);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function inferType(model) {
|
|
35
|
+
if (model.capabilities?.includes("video")) return "video";
|
|
36
|
+
if (model.capabilities?.includes("audio")) return "audio";
|
|
37
|
+
if (model.capabilities?.includes("image")) return "image";
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { createAnimation } from "../src/animation.js";
|
|
5
|
+
|
|
6
|
+
test("does not animate in json mode", () => {
|
|
7
|
+
let output = "";
|
|
8
|
+
const animation = createAnimation({
|
|
9
|
+
json: true,
|
|
10
|
+
stream: { isTTY: true, write: (chunk) => { output += chunk; } },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
animation.start("image");
|
|
14
|
+
animation.stop();
|
|
15
|
+
|
|
16
|
+
assert.equal(output, "");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("uses single log line when stderr is not a tty", () => {
|
|
20
|
+
let output = "";
|
|
21
|
+
const animation = createAnimation({
|
|
22
|
+
json: false,
|
|
23
|
+
stream: { isTTY: false, write: (chunk) => { output += chunk; } },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
animation.start("video");
|
|
27
|
+
animation.stop();
|
|
28
|
+
|
|
29
|
+
assert.equal(output, "flatkey video generation started\n");
|
|
30
|
+
});
|
package/test/api.test.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
FlatkeyError,
|
|
6
|
+
generateAudio,
|
|
7
|
+
generateImage,
|
|
8
|
+
generateVideo,
|
|
9
|
+
getCredits,
|
|
10
|
+
getModels,
|
|
11
|
+
getStatus,
|
|
12
|
+
} from "../src/api.js";
|
|
13
|
+
|
|
14
|
+
function fetchRecorder(responseBody = { ok: true }) {
|
|
15
|
+
const calls = [];
|
|
16
|
+
const fetch = async (url, init) => {
|
|
17
|
+
calls.push({ url, init });
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
status: 200,
|
|
21
|
+
async json() {
|
|
22
|
+
return responseBody;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
return { fetch, calls };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("builds nano image request using gemini-style route", async () => {
|
|
30
|
+
const { fetch, calls } = fetchRecorder({ candidates: [] });
|
|
31
|
+
|
|
32
|
+
await generateImage({
|
|
33
|
+
apiKey: "key",
|
|
34
|
+
baseUrl: "https://router.test",
|
|
35
|
+
prompt: "poster",
|
|
36
|
+
fetch,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
assert.equal(
|
|
40
|
+
calls[0].url,
|
|
41
|
+
"https://router.test/v1beta/models/nano-banana-pro-preview:generateContent?key=key",
|
|
42
|
+
);
|
|
43
|
+
assert.equal(calls[0].init.method, "POST");
|
|
44
|
+
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
|
45
|
+
contents: [{ parts: [{ text: "poster" }] }],
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("builds OpenAI image request for gpt image models", async () => {
|
|
50
|
+
const { fetch, calls } = fetchRecorder({ data: [] });
|
|
51
|
+
|
|
52
|
+
await generateImage({
|
|
53
|
+
apiKey: "key",
|
|
54
|
+
baseUrl: "https://router.test",
|
|
55
|
+
model: "gpt-image-2",
|
|
56
|
+
prompt: "cover",
|
|
57
|
+
size: "1024x1024",
|
|
58
|
+
n: "2",
|
|
59
|
+
fetch,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.equal(calls[0].url, "https://router.test/v1/images/generations");
|
|
63
|
+
assert.equal(calls[0].init.headers.Authorization, "Bearer key");
|
|
64
|
+
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
|
65
|
+
model: "gpt-image-2",
|
|
66
|
+
prompt: "cover",
|
|
67
|
+
size: "1024x1024",
|
|
68
|
+
n: 2,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("builds video generation request", async () => {
|
|
73
|
+
const { fetch, calls } = fetchRecorder({ data: [] });
|
|
74
|
+
|
|
75
|
+
await generateVideo({
|
|
76
|
+
apiKey: "key",
|
|
77
|
+
baseUrl: "https://router.test",
|
|
78
|
+
model: "veo-3",
|
|
79
|
+
prompt: "walkthrough",
|
|
80
|
+
duration: "8",
|
|
81
|
+
aspect: "16:9",
|
|
82
|
+
fps: "24",
|
|
83
|
+
fetch,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
assert.equal(calls[0].url, "https://router.test/v1/videos/generations");
|
|
87
|
+
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
|
88
|
+
model: "veo-3",
|
|
89
|
+
prompt: "walkthrough",
|
|
90
|
+
duration: 8,
|
|
91
|
+
aspect: "16:9",
|
|
92
|
+
fps: 24,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("builds audio generation request", async () => {
|
|
97
|
+
const { fetch, calls } = fetchRecorder({ data: [] });
|
|
98
|
+
|
|
99
|
+
await generateAudio({
|
|
100
|
+
apiKey: "key",
|
|
101
|
+
baseUrl: "https://router.test",
|
|
102
|
+
model: "tts-1",
|
|
103
|
+
prompt: "voiceover",
|
|
104
|
+
voice: "alloy",
|
|
105
|
+
format: "mp3",
|
|
106
|
+
fetch,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
assert.equal(calls[0].url, "https://router.test/v1/audio/generations");
|
|
110
|
+
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
|
111
|
+
model: "tts-1",
|
|
112
|
+
prompt: "voiceover",
|
|
113
|
+
voice: "alloy",
|
|
114
|
+
format: "mp3",
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("builds credits, status, and models requests", async () => {
|
|
119
|
+
const { fetch, calls } = fetchRecorder({});
|
|
120
|
+
|
|
121
|
+
await getCredits({ apiKey: "key", baseUrl: "https://router.test", fetch });
|
|
122
|
+
await getStatus({ apiKey: "key", baseUrl: "https://router.test", fetch });
|
|
123
|
+
await getModels({ apiKey: "key", baseUrl: "https://router.test", fetch });
|
|
124
|
+
|
|
125
|
+
assert.equal(calls[0].url, "https://router.test/v1/credits");
|
|
126
|
+
assert.equal(calls[1].url, "https://router.test/v1/status");
|
|
127
|
+
assert.equal(calls[2].url, "https://router.test/v1/models");
|
|
128
|
+
assert.equal(calls[0].init.headers.Authorization, "Bearer key");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("throws FlatkeyError with API message on HTTP failure", async () => {
|
|
132
|
+
const fetch = async () => ({
|
|
133
|
+
ok: false,
|
|
134
|
+
status: 402,
|
|
135
|
+
async json() {
|
|
136
|
+
return { error: { message: "credits exhausted" } };
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await assert.rejects(
|
|
141
|
+
() => getCredits({ apiKey: "key", baseUrl: "https://router.test", fetch }),
|
|
142
|
+
(error) => error instanceof FlatkeyError && /credits exhausted/.test(error.message),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile } 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 { persistArtifacts } from "../src/artifacts.js";
|
|
8
|
+
|
|
9
|
+
test("saves OpenAI base64 image artifacts", async () => {
|
|
10
|
+
const outDir = await mkdtemp(join(tmpdir(), "flatkey-artifacts-"));
|
|
11
|
+
|
|
12
|
+
const artifacts = await persistArtifacts({
|
|
13
|
+
kind: "image",
|
|
14
|
+
response: { data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }] },
|
|
15
|
+
outDir,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
assert.equal(artifacts.length, 1);
|
|
19
|
+
assert.match(artifacts[0].path, /image-01\.png$/);
|
|
20
|
+
assert.equal(await readFile(artifacts[0].path, "utf8"), "png-bytes");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("saves data url video artifacts with mime extension", async () => {
|
|
24
|
+
const outDir = await mkdtemp(join(tmpdir(), "flatkey-artifacts-"));
|
|
25
|
+
|
|
26
|
+
const artifacts = await persistArtifacts({
|
|
27
|
+
kind: "video",
|
|
28
|
+
response: {
|
|
29
|
+
data: [{ url: `data:video/mp4;base64,${Buffer.from("mp4-bytes").toString("base64")}` }],
|
|
30
|
+
},
|
|
31
|
+
outDir,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
assert.match(artifacts[0].path, /video-01\.mp4$/);
|
|
35
|
+
assert.equal(await readFile(artifacts[0].path, "utf8"), "mp4-bytes");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("preserves remote url artifacts without downloading", async () => {
|
|
39
|
+
const outDir = await mkdtemp(join(tmpdir(), "flatkey-artifacts-"));
|
|
40
|
+
|
|
41
|
+
const artifacts = await persistArtifacts({
|
|
42
|
+
kind: "audio",
|
|
43
|
+
response: { data: [{ url: "https://cdn.test/audio.mp3" }] },
|
|
44
|
+
outDir,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assert.deepEqual(artifacts, [{ url: "https://cdn.test/audio.mp3" }]);
|
|
48
|
+
});
|
package/test/cli.test.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { parseArgv } 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 ai help", () => {
|
|
32
|
+
const command = parseArgv(["help", "--ai"]);
|
|
33
|
+
|
|
34
|
+
assert.deepEqual(command, {
|
|
35
|
+
group: "help",
|
|
36
|
+
action: undefined,
|
|
37
|
+
options: {
|
|
38
|
+
ai: true,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects unknown commands", () => {
|
|
44
|
+
assert.throws(
|
|
45
|
+
() => parseArgv(["wat"]),
|
|
46
|
+
/Unknown command: wat/,
|
|
47
|
+
);
|
|
48
|
+
});
|