ai-cli 0.1.0 → 0.2.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/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # ai
2
+
3
+ A tiny, agent-native CLI for generating images, video and text with dead-simple commands, stdin support and predictable artifact outputs. Uses [Vercel AI SDK](https://sdk.vercel.ai) and [AI Gateway](https://vercel.com/docs/ai-gateway) for unified access to hundreds of models.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g ai-cli
9
+ ```
10
+
11
+ Requires an [AI Gateway](https://vercel.com/docs/ai-gateway) API key or a provider-specific key (e.g. `OPENAI_API_KEY`).
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ ai image "a cute dog"
17
+ ai video "a spinning triangle"
18
+ ai text "explain quantum computing"
19
+ ai models # list available models
20
+ ```
21
+
22
+ ### Piping
23
+
24
+ ```bash
25
+ ai image "a dragon" | ai video "animate this"
26
+ cat notes.txt | ai text "summarize this"
27
+ git diff | ai text "explain these changes"
28
+ ```
29
+
30
+ ### Common Options
31
+
32
+ All commands support:
33
+
34
+ ```
35
+ -m, --model <id> Model ID (creator/model-name), comma-separated for multi-model
36
+ -o, --output <path> Output file path or directory
37
+ -n, --count <n> Number of generations per model (default: 1)
38
+ -p, --concurrency <n> Max parallel generations (default: 4, video: 2)
39
+ -q, --quiet Suppress progress output
40
+ --json Output metadata as JSON
41
+ ```
42
+
43
+ Model IDs can be specified as `creator/model-name` or just `model-name` (resolved against models fetched from the gateway):
44
+
45
+ ```bash
46
+ ai text -m gpt-5.5 "hello" # resolves to openai/gpt-5.5
47
+ ai image -m flux-2-pro "a sunset" # resolves to bfl/flux-2-pro
48
+ ```
49
+
50
+ ### image
51
+
52
+ ```
53
+ --size <WxH> Image size (e.g. 1024x1024)
54
+ --aspect-ratio <W:H> Aspect ratio (e.g. 16:9)
55
+ --quality <level> Quality (standard, hd)
56
+ --style <style> Style (vivid, natural)
57
+ --no-preview Disable inline image preview
58
+ ```
59
+
60
+ ### video
61
+
62
+ ```
63
+ --aspect-ratio <W:H> Aspect ratio (e.g. 16:9)
64
+ --duration <seconds> Duration in seconds
65
+ --no-preview Disable inline video frame preview
66
+ ```
67
+
68
+ ### text
69
+
70
+ ```
71
+ -f, --format <fmt> Output format: md, txt (default: md)
72
+ -s, --system <prompt> System prompt
73
+ --max-tokens <n> Maximum tokens to generate
74
+ -t, --temperature <n> Temperature (0-2)
75
+ ```
76
+
77
+ ### models
78
+
79
+ ```
80
+ --type <type> Filter by type: text, image, video
81
+ --creator <name> Filter by creator (e.g. openai, google)
82
+ --json Output as JSON (includes descriptions)
83
+ ```
84
+
85
+ All model types (text, image, video) are fetched live from the AI Gateway.
86
+
87
+ ### Multi-Model Comparison
88
+
89
+ Generate with multiple models by comma-separating `-m`:
90
+
91
+ ```bash
92
+ ai image "a sunset" -m "openai/gpt-image-1,xai/grok-imagine-image,bfl/flux-2-pro"
93
+ ```
94
+
95
+ Combine with `-n` to generate multiple per model:
96
+
97
+ ```bash
98
+ ai image "a sunset" -n 2 -m "openai/gpt-image-1,bfl/flux-2-pro" # 4 images total
99
+ ```
100
+
101
+ ### Inline Preview
102
+
103
+ When running in a terminal that supports the [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) (Kitty, Ghostty, WezTerm, Warp, iTerm2), generated images and videos are displayed inline automatically. Video previews decode an H.264 keyframe from the midpoint of the video using [openh264](https://github.com/cisco/openh264) compiled to WebAssembly — no native dependencies required. Use `--no-preview` to disable this, or set `AI_CLI_PREVIEW=1` to force it on in undetected terminals.
104
+
105
+ ### Output Behavior
106
+
107
+ - **text**: saves to `output.md` (interactive), stdout when piped
108
+ - **image/video**: saves to file (interactive), raw binary stdout when piped
109
+ - **`-o <dir>`**: saves inside the directory with auto-generated names
110
+
111
+ ### Environment Variables
112
+
113
+ | Variable | Description |
114
+ |---|---|
115
+ | `AI_GATEWAY_API_KEY` | AI Gateway authentication key |
116
+ | `OPENAI_API_KEY` | Provider-specific key (or other provider keys) |
117
+ | `AI_CLI_TEXT_MODEL` | Default text model (overrides `openai/gpt-5.5`) |
118
+ | `AI_CLI_IMAGE_MODEL` | Default image model (overrides `openai/gpt-image-2`) |
119
+ | `AI_CLI_VIDEO_MODEL` | Default video model (overrides `bytedance/seedance-2.0`) |
120
+ | `AI_CLI_OUTPUT_DIR` | Default output directory for generated files |
121
+ | `AI_CLI_PREVIEW` | Set to `1` to force inline image preview, `0` to disable |
122
+ | `NO_COLOR` | Disable ANSI color output |
123
+ | `FORCE_COLOR` | Force color output even when not a TTY |
124
+
125
+ The `-m` flag always takes priority over `AI_CLI_*_MODEL` env vars. The `-o` flag always takes priority over `AI_CLI_OUTPUT_DIR`.
126
+
127
+ ### Timeouts
128
+
129
+ Requests that exceed the timeout are aborted automatically:
130
+
131
+ | Command | Timeout |
132
+ |---|---|
133
+ | `text` | 120 seconds |
134
+ | `image` | 120 seconds |
135
+ | `video` | 300 seconds |
136
+
137
+ ### Exit Codes
138
+
139
+ | Code | Meaning |
140
+ |---|---|
141
+ | `0` | Success |
142
+ | `1` | All generations failed |
143
+ | `2` | Partial failure (some succeeded, some failed) |
144
+
145
+ ## License
146
+
147
+ [Apache-2.0](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A tiny, agent-native CLI for generating images, video and text with dead-simple commands, stdin support and predictable artifact outputs",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/cli.test.ts CHANGED
@@ -22,7 +22,7 @@ describe("cli integration", () => {
22
22
  test("--help exits 0 and lists subcommands", async () => {
23
23
  const { exitCode, stdout } = await run("--help");
24
24
  expect(exitCode).toBe(0);
25
- for (const sub of ["text", "image", "video", "models", "completions"]) {
25
+ for (const sub of ["text", "image", "video", "models"]) {
26
26
  expect(stdout).toContain(sub);
27
27
  }
28
28
  });
@@ -33,31 +33,6 @@ describe("cli integration", () => {
33
33
  expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
34
34
  });
35
35
 
36
- test("completions zsh exits 0 with valid output", async () => {
37
- const { exitCode, stdout } = await run("completions", "zsh");
38
- expect(exitCode).toBe(0);
39
- expect(stdout).toContain("#compdef ai");
40
- expect(stdout).toContain("--no-preview");
41
- });
42
-
43
- test("completions bash exits 0 with valid output", async () => {
44
- const { exitCode, stdout } = await run("completions", "bash");
45
- expect(exitCode).toBe(0);
46
- expect(stdout).toContain("complete -F");
47
- });
48
-
49
- test("completions fish exits 0 with valid output", async () => {
50
- const { exitCode, stdout } = await run("completions", "fish");
51
- expect(exitCode).toBe(0);
52
- expect(stdout).toContain("complete -c ai");
53
- });
54
-
55
- test("completions with invalid shell exits 1", async () => {
56
- const { exitCode, stderr } = await run("completions", "powershell");
57
- expect(exitCode).toBe(1);
58
- expect(stderr).toContain("Unknown shell");
59
- });
60
-
61
36
  test("text with no prompt and no stdin exits 1", async () => {
62
37
  const { exitCode, stderr } = await run("text");
63
38
  expect(exitCode).toBe(1);
@@ -1,8 +1,8 @@
1
- import { generateImage, gateway } from "ai";
1
+ import { generateImage, generateText, gateway } from "ai";
2
2
  import type { Command } from "commander";
3
3
 
4
4
  import { buildJobs, runJobs } from "../lib/jobs.js";
5
- import { resolveModels } from "../lib/models.js";
5
+ import { fetchGatewayModels, resolveModels } from "../lib/models.js";
6
6
  import { parsePositiveInt, parseSize, parseAspectRatio } from "../lib/parse.js";
7
7
  import { readStdin } from "../lib/stdin.js";
8
8
 
@@ -57,15 +57,17 @@ export function registerImageCommand(program: Command) {
57
57
  );
58
58
  process.exit(1);
59
59
  }
60
- let imagePrompt: string | { images: Uint8Array[]; text?: string } =
61
- prompt!;
60
+ let imagePrompt: string | { images: Uint8Array[]; text?: string };
62
61
  if (stdin) {
63
62
  imagePrompt = prompt
64
63
  ? { images: [new Uint8Array(stdin)], text: prompt }
65
64
  : { images: [new Uint8Array(stdin)] };
65
+ } else {
66
+ imagePrompt = prompt!;
66
67
  }
67
68
 
68
- const models = resolveModels("image", opts.model);
69
+ const gatewayModels = await fetchGatewayModels();
70
+ const models = resolveModels("image", opts.model, gatewayModels.image);
69
71
  const countPerModel = opts.count
70
72
  ? parsePositiveInt(opts.count, "count")
71
73
  : 1;
@@ -90,7 +92,62 @@ export function registerImageCommand(program: Command) {
90
92
  jobs,
91
93
  async (modelId) => {
92
94
  const abort = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
95
+
96
+ if (gatewayModels.languageImageModelIds.has(modelId)) {
97
+ const messageContent: Array<
98
+ | { type: "text"; text: string }
99
+ | { type: "image"; image: Uint8Array }
100
+ > = [];
101
+ if (typeof imagePrompt === "string") {
102
+ messageContent.push({ type: "text", text: imagePrompt });
103
+ } else {
104
+ for (const img of imagePrompt.images) {
105
+ messageContent.push({ type: "image", image: img });
106
+ }
107
+ if (imagePrompt.text) {
108
+ messageContent.push({
109
+ type: "text",
110
+ text: imagePrompt.text,
111
+ });
112
+ } else {
113
+ messageContent.push({
114
+ type: "text",
115
+ text: "Generate an image",
116
+ });
117
+ }
118
+ }
119
+ const creator = gatewayModels.all.find(
120
+ (m) => m.id === modelId
121
+ )?.creator;
122
+ const result = await generateText({
123
+ headers: {
124
+ "http-referer": "https://github.com/vercel-labs/ai-cli",
125
+ "x-title": "ai-cli",
126
+ },
127
+ model: gateway(modelId),
128
+ messages: [{ role: "user", content: messageContent }],
129
+ abortSignal: abort,
130
+ providerOptions:
131
+ creator === "google"
132
+ ? { google: { responseModalities: ["IMAGE", "TEXT"] } }
133
+ : undefined,
134
+ });
135
+ const imageFile = result.files?.find((f) =>
136
+ f.mediaType.startsWith("image/")
137
+ );
138
+ if (!imageFile) {
139
+ throw new Error(
140
+ `Model ${modelId} did not return an image in the response`
141
+ );
142
+ }
143
+ return Buffer.from(imageFile.uint8Array);
144
+ }
145
+
93
146
  const result = await generateImage({
147
+ headers: {
148
+ "http-referer": "https://github.com/vercel-labs/ai-cli",
149
+ "x-title": "ai-cli",
150
+ },
94
151
  model: gateway.image(modelId),
95
152
  prompt: imagePrompt,
96
153
  abortSignal: abort,
@@ -1,14 +1,16 @@
1
1
  import type { Command } from "commander";
2
2
 
3
- import { fetchGatewayModels, type ModelEntry } from "../lib/models.js";
3
+ import {
4
+ fetchGatewayModels,
5
+ type Modality,
6
+ type ModelEntry,
7
+ } from "../lib/models.js";
4
8
 
5
- function groupByProvider(models: ModelEntry[]): Map<string, ModelEntry[]> {
9
+ function groupByCreator(models: ModelEntry[]): Map<string, ModelEntry[]> {
6
10
  const groups = new Map<string, ModelEntry[]>();
7
11
  for (const m of models) {
8
- const slash = m.id.indexOf("/");
9
- const provider = slash !== -1 ? m.id.slice(0, slash) : "other";
10
- if (!groups.has(provider)) groups.set(provider, []);
11
- groups.get(provider)!.push(m);
12
+ if (!groups.has(m.creator)) groups.set(m.creator, []);
13
+ groups.get(m.creator)!.push(m);
12
14
  }
13
15
  return new Map(
14
16
  [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))
@@ -25,61 +27,42 @@ export function registerModelsCommand(program: Command) {
25
27
  .command("models")
26
28
  .description("List available models from AI Gateway")
27
29
  .option("--type <type>", "Filter by type: text, image, video")
28
- .option("--provider <name>", "Filter by provider (e.g. openai, google)")
30
+ .option("--creator <name>", "Filter by creator (e.g. openai, google)")
29
31
  .option("--json", "Output as JSON (includes descriptions)")
30
32
  .action(
31
- async (opts: { type?: string; provider?: string; json?: boolean }) => {
33
+ async (opts: { type?: string; creator?: string; json?: boolean }) => {
32
34
  const validTypes = ["text", "image", "video"];
33
- const filterType = opts.type?.toLowerCase();
35
+ const filterType = opts.type?.toLowerCase() as Modality | undefined;
34
36
  if (filterType && !validTypes.includes(filterType)) {
35
37
  process.stderr.write(
36
38
  `Error: --type must be one of: ${validTypes.join(", ")} (got "${opts.type}")\n`
37
39
  );
38
40
  process.exit(1);
39
41
  }
40
- const filterProvider = opts.provider?.toLowerCase();
42
+ const filterCreator = opts.creator?.toLowerCase();
41
43
 
42
44
  const gatewayModels = await fetchGatewayModels();
43
45
 
44
- const filterGrouped = (grouped: Map<string, ModelEntry[]>) => {
45
- if (!filterProvider) return grouped;
46
- const filtered = new Map<string, ModelEntry[]>();
47
- for (const [provider, models] of grouped) {
48
- if (provider.toLowerCase() === filterProvider) {
49
- filtered.set(provider, models);
50
- }
51
- }
52
- return filtered;
53
- };
54
-
55
46
  if (opts.json) {
56
- const output: Record<string, unknown> = {};
57
- const jsonMapper = (m: ModelEntry) => ({
58
- id: m.id,
59
- ...(m.name ? { name: m.name } : {}),
60
- ...(m.description ? { description: m.description } : {}),
61
- });
62
- if (!filterType || filterType === "text") {
63
- output.text = Object.fromEntries(
64
- [...filterGrouped(groupByProvider(gatewayModels.text))].map(
65
- ([provider, models]) => [provider, models.map(jsonMapper)]
66
- )
47
+ let entries = gatewayModels.all;
48
+ if (filterType) {
49
+ entries = entries.filter((m) =>
50
+ m.capabilities.includes(filterType)
67
51
  );
68
52
  }
69
- if (!filterType || filterType === "image") {
70
- output.image = Object.fromEntries(
71
- [...filterGrouped(groupByProvider(gatewayModels.image))].map(
72
- ([provider, models]) => [provider, models.map(jsonMapper)]
73
- )
74
- );
75
- }
76
- if (!filterType || filterType === "video") {
77
- output.video = Object.fromEntries(
78
- [...filterGrouped(groupByProvider(gatewayModels.video))].map(
79
- ([provider, models]) => [provider, models.map(jsonMapper)]
80
- )
53
+ if (filterCreator) {
54
+ entries = entries.filter(
55
+ (m) => m.creator.toLowerCase() === filterCreator
81
56
  );
82
57
  }
58
+ const output = entries.map((m) => ({
59
+ id: m.id,
60
+ ...(m.name ? { name: m.name } : {}),
61
+ ...(m.description ? { description: m.description } : {}),
62
+ creator: m.creator,
63
+ capabilities: m.capabilities,
64
+ ...(m.pricing ? { pricing: m.pricing } : {}),
65
+ }));
83
66
  process.stdout.write(JSON.stringify(output, null, 2) + "\n");
84
67
  return;
85
68
  }
@@ -94,13 +77,19 @@ export function registerModelsCommand(program: Command) {
94
77
 
95
78
  let totalCount = 0;
96
79
  for (const section of sections) {
97
- const grouped = filterGrouped(groupByProvider(section.entries));
80
+ let entries = section.entries;
81
+ if (filterCreator) {
82
+ entries = entries.filter(
83
+ (m) => m.creator.toLowerCase() === filterCreator
84
+ );
85
+ }
86
+ const grouped = groupByCreator(entries);
98
87
  const count = [...grouped.values()].reduce((s, m) => s + m.length, 0);
99
88
  if (count === 0) continue;
100
89
  totalCount += count;
101
90
  process.stdout.write(`\n${section.title} models (${count}):\n`);
102
- for (const [provider, models] of grouped) {
103
- process.stdout.write(`\n ${provider}\n`);
91
+ for (const [creator, models] of grouped) {
92
+ process.stdout.write(`\n ${creator}\n`);
104
93
  for (const m of models) {
105
94
  process.stdout.write(` ${modelName(m.id)}\n`);
106
95
  }
@@ -2,7 +2,7 @@ import { generateText, gateway } from "ai";
2
2
  import type { Command } from "commander";
3
3
 
4
4
  import { buildJobs, runJobs } from "../lib/jobs.js";
5
- import { resolveModels } from "../lib/models.js";
5
+ import { fetchGatewayModels, resolveModels } from "../lib/models.js";
6
6
  import type { OutputFormat } from "../lib/output.js";
7
7
  import { parsePositiveInt, parseTemperature } from "../lib/parse.js";
8
8
  import { readStdin, stdinAsText } from "../lib/stdin.js";
@@ -69,7 +69,8 @@ export function registerTextCommand(program: Command) {
69
69
  }
70
70
 
71
71
  const format = resolveFormat(opts.format);
72
- const models = resolveModels("text", opts.model);
72
+ const gatewayModels = await fetchGatewayModels();
73
+ const models = resolveModels("text", opts.model, gatewayModels.text);
73
74
  const countPerModel = opts.count
74
75
  ? parsePositiveInt(opts.count, "count")
75
76
  : 1;
@@ -87,6 +88,10 @@ export function registerTextCommand(program: Command) {
87
88
  async (modelId) => {
88
89
  const abort = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
89
90
  const result = await generateText({
91
+ headers: {
92
+ "http-referer": "https://github.com/vercel-labs/ai-cli",
93
+ "x-title": "ai-cli",
94
+ },
90
95
  model: gateway(modelId),
91
96
  prompt: fullPrompt,
92
97
  system: opts.system,
@@ -2,7 +2,7 @@ import { experimental_generateVideo as generateVideo, gateway } from "ai";
2
2
  import type { Command } from "commander";
3
3
 
4
4
  import { buildJobs, runJobs } from "../lib/jobs.js";
5
- import { resolveModels } from "../lib/models.js";
5
+ import { fetchGatewayModels, resolveModels } from "../lib/models.js";
6
6
  import {
7
7
  parsePositiveInt,
8
8
  parseAspectRatio,
@@ -65,7 +65,8 @@ export function registerVideoCommand(program: Command) {
65
65
  : { image: new Uint8Array(stdin) };
66
66
  }
67
67
 
68
- const models = resolveModels("video", opts.model);
68
+ const gatewayModels = await fetchGatewayModels();
69
+ const models = resolveModels("video", opts.model, gatewayModels.video);
69
70
  const countPerModel = opts.count
70
71
  ? parsePositiveInt(opts.count, "count")
71
72
  : 1;
@@ -83,6 +84,10 @@ export function registerVideoCommand(program: Command) {
83
84
  async (modelId) => {
84
85
  const abort = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
85
86
  const result = await generateVideo({
87
+ headers: {
88
+ "http-referer": "https://github.com/vercel-labs/ai-cli",
89
+ "x-title": "ai-cli",
90
+ },
86
91
  model: gateway.video(modelId),
87
92
  prompt: videoPrompt,
88
93
  abortSignal: abort,
package/src/index.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  import { Command } from "commander";
3
3
 
4
4
  import pkg from "../package.json";
5
- import { registerCompletionsCommand } from "./commands/completions.js";
6
5
  import { registerImageCommand } from "./commands/image.js";
7
6
  import { registerModelsCommand } from "./commands/models.js";
8
7
  import { registerTextCommand } from "./commands/text.js";
@@ -21,7 +20,6 @@ registerTextCommand(program);
21
20
  registerImageCommand(program);
22
21
  registerVideoCommand(program);
23
22
  registerModelsCommand(program);
24
- registerCompletionsCommand(program);
25
23
 
26
24
  program.parseAsync(process.argv).catch((err: unknown) => {
27
25
  const msg = err instanceof Error ? err.message : String(err);