ai-cli 0.0.12 → 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/package.json CHANGED
@@ -1,49 +1,48 @@
1
1
  {
2
2
  "name": "ai-cli",
3
- "version": "0.0.12",
4
- "main": "dist/ai.mjs",
5
- "bin": {
6
- "ai": "dist/ai.mjs"
3
+ "version": "0.1.0",
4
+ "description": "A tiny, agent-native CLI for generating images, video and text with dead-simple commands, stdin support and predictable artifact outputs",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/vercel-labs/ai-cli.git"
7
10
  },
8
- "engines": {
9
- "node": ">=18.0.0"
11
+ "bin": {
12
+ "ai": "./src/index.ts"
10
13
  },
11
14
  "files": [
12
- "dist/",
15
+ "src",
13
16
  "README.md"
14
17
  ],
18
+ "keywords": [
19
+ "ai",
20
+ "cli",
21
+ "generative",
22
+ "image",
23
+ "video",
24
+ "text",
25
+ "ai-sdk",
26
+ "vercel"
27
+ ],
15
28
  "scripts": {
16
- "build": "node build.mjs",
17
- "test": "bun test tests/*.test.ts",
18
- "test:coverage": "bun test --coverage tests/*.test.ts",
19
- "test:e2e": "bun test tests/e2e/",
20
- "test:evals": "bun test tests/evals/",
21
- "test:evals:matrix": "bun run tests/evals/run-matrix.ts",
22
- "lint": "biome lint .",
23
- "format": "biome format . --write",
29
+ "dev": "bun run src/index.ts",
30
+ "build": "bun build src/index.ts --compile --outfile=dist/ai",
24
31
  "typecheck": "tsc --noEmit",
25
- "format:check": "biome format .",
26
- "check": "biome check .",
27
- "prepublishOnly": "bun run build"
32
+ "format": "oxfmt --write src/",
33
+ "format:check": "oxfmt --check src/",
34
+ "lint": "oxlint src/",
35
+ "test": "bun test",
36
+ "prepublishOnly": "bun run typecheck"
28
37
  },
29
- "type": "module",
30
38
  "dependencies": {
31
- "@ai-sdk/gateway": "3.0.40",
32
- "@ai-sdk/mcp": "^1.0.18",
33
- "@mozilla/readability": "^0.6.0",
34
- "linkedom": "^0.18.12"
39
+ "ai": "^6.0.173",
40
+ "commander": "^14.0.3"
35
41
  },
36
42
  "devDependencies": {
37
- "@ai-cli/typescript-config": "workspace:*",
38
- "@types/node": "^24.1.0",
39
- "@xterm/headless": "^6.0.0",
40
- "ai": "6.0.79",
41
- "ansi-escapes": "^7.3.0",
42
- "arg": "^5.0.2",
43
- "esbuild": "^0.25.8",
44
- "rc9": "^2.1.2",
45
- "typescript": "^5.8.3",
46
- "yoctocolors": "^2.1.1",
47
- "zod": "^4.1.8"
43
+ "@types/bun": "^1.3.13",
44
+ "oxfmt": "^0.47.0",
45
+ "oxlint": "^1.62.0",
46
+ "typescript": "^6.0.3"
48
47
  }
49
48
  }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ const CLI = ["bun", "run", "src/index.ts"];
4
+ const ROOT = import.meta.dir + "/..";
5
+
6
+ async function run(...args: string[]) {
7
+ const proc = Bun.spawn([...CLI, ...args], {
8
+ cwd: ROOT,
9
+ stdout: "pipe",
10
+ stderr: "pipe",
11
+ stdin: "ignore",
12
+ });
13
+ const [stdout, stderr] = await Promise.all([
14
+ new Response(proc.stdout).text(),
15
+ new Response(proc.stderr).text(),
16
+ ]);
17
+ const exitCode = await proc.exited;
18
+ return { exitCode, stdout, stderr };
19
+ }
20
+
21
+ describe("cli integration", () => {
22
+ test("--help exits 0 and lists subcommands", async () => {
23
+ const { exitCode, stdout } = await run("--help");
24
+ expect(exitCode).toBe(0);
25
+ for (const sub of ["text", "image", "video", "models", "completions"]) {
26
+ expect(stdout).toContain(sub);
27
+ }
28
+ });
29
+
30
+ test("--version exits 0 and prints semver", async () => {
31
+ const { exitCode, stdout } = await run("--version");
32
+ expect(exitCode).toBe(0);
33
+ expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
34
+ });
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
+ test("text with no prompt and no stdin exits 1", async () => {
62
+ const { exitCode, stderr } = await run("text");
63
+ expect(exitCode).toBe(1);
64
+ expect(stderr).toContain("prompt is required");
65
+ });
66
+
67
+ test("text --help exits 0 and lists flags", async () => {
68
+ const { exitCode, stdout } = await run("text", "--help");
69
+ expect(exitCode).toBe(0);
70
+ expect(stdout).toContain("--model");
71
+ expect(stdout).toContain("--format");
72
+ expect(stdout).toContain("--temperature");
73
+ });
74
+
75
+ test("image --help exits 0 and lists flags", async () => {
76
+ const { exitCode, stdout } = await run("image", "--help");
77
+ expect(exitCode).toBe(0);
78
+ expect(stdout).toContain("--no-preview");
79
+ expect(stdout).toContain("--size");
80
+ expect(stdout).toContain("--aspect-ratio");
81
+ });
82
+
83
+ test("video --help exits 0 and lists flags", async () => {
84
+ const { exitCode, stdout } = await run("video", "--help");
85
+ expect(exitCode).toBe(0);
86
+ expect(stdout).toContain("--duration");
87
+ expect(stdout).toContain("--aspect-ratio");
88
+ });
89
+
90
+ test("models --type invalid exits 1", async () => {
91
+ const { exitCode, stderr } = await run("models", "--type", "audio");
92
+ expect(exitCode).toBe(1);
93
+ expect(stderr).toContain("must be one of");
94
+ });
95
+ });
@@ -0,0 +1,296 @@
1
+ import type { Command } from "commander";
2
+
3
+ import {
4
+ FALLBACK_TEXT_MODELS,
5
+ FALLBACK_IMAGE_MODELS,
6
+ FALLBACK_VIDEO_MODELS,
7
+ } from "../lib/models.js";
8
+
9
+ export function registerCompletionsCommand(program: Command) {
10
+ program
11
+ .command("completions")
12
+ .description("Output shell completion script")
13
+ .argument("<shell>", "Shell type: zsh, bash, fish")
14
+ .action((shell: string) => {
15
+ switch (shell.toLowerCase()) {
16
+ case "zsh":
17
+ process.stdout.write(generateZsh());
18
+ break;
19
+ case "bash":
20
+ process.stdout.write(generateBash());
21
+ break;
22
+ case "fish":
23
+ process.stdout.write(generateFish());
24
+ break;
25
+ default:
26
+ process.stderr.write(
27
+ `Unknown shell: ${shell}. Supported: zsh, bash, fish\n`
28
+ );
29
+ process.exit(1);
30
+ }
31
+ });
32
+ }
33
+
34
+ const SUBCOMMANDS = ["text", "image", "video", "models", "completions", "help"];
35
+ const GLOBAL_FLAGS = [
36
+ "--model",
37
+ "--output",
38
+ "--count",
39
+ "--concurrency",
40
+ "--quiet",
41
+ "--json",
42
+ "--help",
43
+ "--version",
44
+ ];
45
+ const TEXT_FLAGS = ["--format", "--system", "--max-tokens", "--temperature"];
46
+ const IMAGE_FLAGS = [
47
+ "--size",
48
+ "--aspect-ratio",
49
+ "--quality",
50
+ "--style",
51
+ "--no-preview",
52
+ ];
53
+ const VIDEO_FLAGS = ["--aspect-ratio", "--duration", "--no-preview"];
54
+ const MODEL_FLAGS = ["--type", "--provider", "--json", "--help"];
55
+
56
+ const ALL_MODELS = [
57
+ ...FALLBACK_TEXT_MODELS,
58
+ ...FALLBACK_IMAGE_MODELS,
59
+ ...FALLBACK_VIDEO_MODELS,
60
+ ];
61
+ const MODEL_NAMES = ALL_MODELS.map((m) => m.slice(m.indexOf("/") + 1));
62
+
63
+ function generateZsh(): string {
64
+ return `#compdef ai
65
+
66
+ _ai() {
67
+ local -a subcommands
68
+ subcommands=(
69
+ 'text:Generate text from a prompt'
70
+ 'image:Generate an image from a prompt'
71
+ 'video:Generate a video from a prompt'
72
+ 'models:List available models'
73
+ 'completions:Output shell completion script'
74
+ 'help:Display help'
75
+ )
76
+
77
+ local -a models
78
+ models=(${ALL_MODELS.join(" ")})
79
+
80
+ local -a model_names
81
+ model_names=(${MODEL_NAMES.join(" ")})
82
+
83
+ _arguments -C \\
84
+ '1:command:->cmd' \\
85
+ '*::arg:->args'
86
+
87
+ case $state in
88
+ cmd)
89
+ _describe 'command' subcommands
90
+ ;;
91
+ args)
92
+ case $words[1] in
93
+ text)
94
+ _arguments \\
95
+ '-m[Model ID]:model:($models $model_names)' \\
96
+ '--model[Model ID]:model:($models $model_names)' \\
97
+ '-o[Output path]:file:_files' \\
98
+ '--output[Output path]:file:_files' \\
99
+ '-f[Format]:format:(md txt)' \\
100
+ '--format[Format]:format:(md txt)' \\
101
+ '-n[Count]:count:' \\
102
+ '--count[Count]:count:' \\
103
+ '-p[Concurrency]:concurrency:' \\
104
+ '--concurrency[Concurrency]:concurrency:' \\
105
+ '-s[System prompt]:system:' \\
106
+ '--system[System prompt]:system:' \\
107
+ '--max-tokens[Max tokens]:tokens:' \\
108
+ '-t[Temperature]:temp:' \\
109
+ '--temperature[Temperature]:temp:' \\
110
+ '-q[Quiet]' \\
111
+ '--quiet[Quiet]' \\
112
+ '--json[JSON output]' \\
113
+ '*:prompt:'
114
+ ;;
115
+ image)
116
+ _arguments \\
117
+ '-m[Model ID]:model:($models $model_names)' \\
118
+ '--model[Model ID]:model:($models $model_names)' \\
119
+ '-o[Output path]:file:_files' \\
120
+ '--output[Output path]:file:_files' \\
121
+ '-n[Count]:count:' \\
122
+ '--count[Count]:count:' \\
123
+ '-p[Concurrency]:concurrency:' \\
124
+ '--concurrency[Concurrency]:concurrency:' \\
125
+ '--size[Size]:size:' \\
126
+ '--aspect-ratio[Aspect ratio]:ratio:' \\
127
+ '--quality[Quality]:quality:(standard hd)' \\
128
+ '--style[Style]:style:(vivid natural)' \\
129
+ '--no-preview[Disable inline image preview]' \\
130
+ '-q[Quiet]' \\
131
+ '--quiet[Quiet]' \\
132
+ '--json[JSON output]' \\
133
+ '*:prompt:'
134
+ ;;
135
+ video)
136
+ _arguments \\
137
+ '-m[Model ID]:model:($models $model_names)' \\
138
+ '--model[Model ID]:model:($models $model_names)' \\
139
+ '-o[Output path]:file:_files' \\
140
+ '--output[Output path]:file:_files' \\
141
+ '-n[Count]:count:' \\
142
+ '--count[Count]:count:' \\
143
+ '-p[Concurrency]:concurrency:' \\
144
+ '--concurrency[Concurrency]:concurrency:' \\
145
+ '--aspect-ratio[Aspect ratio]:ratio:' \\
146
+ '--duration[Duration]:seconds:' \\
147
+ '--no-preview[Disable inline video frame preview]' \\
148
+ '-q[Quiet]' \\
149
+ '--quiet[Quiet]' \\
150
+ '--json[JSON output]' \\
151
+ '*:prompt:'
152
+ ;;
153
+ models)
154
+ _arguments \\
155
+ '--type[Filter by type]:type:(text image video)' \\
156
+ '--provider[Filter by provider]:provider:' \\
157
+ '--json[JSON output]'
158
+ ;;
159
+ completions)
160
+ _arguments '1:shell:(zsh bash fish)'
161
+ ;;
162
+ esac
163
+ ;;
164
+ esac
165
+ }
166
+
167
+ _ai "$@"
168
+ `;
169
+ }
170
+
171
+ function generateBash(): string {
172
+ return `_ai_completions() {
173
+ local cur prev subcmd
174
+ COMPREPLY=()
175
+ cur="\${COMP_WORDS[COMP_CWORD]}"
176
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
177
+ subcmd="\${COMP_WORDS[1]}"
178
+
179
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
180
+ COMPREPLY=($(compgen -W "${SUBCOMMANDS.join(" ")}" -- "$cur"))
181
+ return
182
+ fi
183
+
184
+ case "$prev" in
185
+ -m|--model)
186
+ COMPREPLY=($(compgen -W "${ALL_MODELS.join(" ")} ${MODEL_NAMES.join(" ")}" -- "$cur"))
187
+ return
188
+ ;;
189
+ -o|--output)
190
+ COMPREPLY=($(compgen -f -- "$cur"))
191
+ return
192
+ ;;
193
+ -f|--format)
194
+ COMPREPLY=($(compgen -W "md txt" -- "$cur"))
195
+ return
196
+ ;;
197
+ --quality)
198
+ COMPREPLY=($(compgen -W "standard hd" -- "$cur"))
199
+ return
200
+ ;;
201
+ --style)
202
+ COMPREPLY=($(compgen -W "vivid natural" -- "$cur"))
203
+ return
204
+ ;;
205
+ --type)
206
+ COMPREPLY=($(compgen -W "text image video" -- "$cur"))
207
+ return
208
+ ;;
209
+ esac
210
+
211
+ case "$subcmd" in
212
+ text)
213
+ COMPREPLY=($(compgen -W "${[...GLOBAL_FLAGS, ...TEXT_FLAGS].join(" ")}" -- "$cur"))
214
+ ;;
215
+ image)
216
+ COMPREPLY=($(compgen -W "${[...GLOBAL_FLAGS, ...IMAGE_FLAGS].join(" ")}" -- "$cur"))
217
+ ;;
218
+ video)
219
+ COMPREPLY=($(compgen -W "${[...GLOBAL_FLAGS, ...VIDEO_FLAGS].join(" ")}" -- "$cur"))
220
+ ;;
221
+ models)
222
+ COMPREPLY=($(compgen -W "${MODEL_FLAGS.join(" ")}" -- "$cur"))
223
+ ;;
224
+ completions)
225
+ COMPREPLY=($(compgen -W "zsh bash fish" -- "$cur"))
226
+ ;;
227
+ esac
228
+ }
229
+
230
+ complete -F _ai_completions ai
231
+ `;
232
+ }
233
+
234
+ function generateFish(): string {
235
+ const lines: string[] = [];
236
+ lines.push("# ai completions for fish");
237
+ lines.push("");
238
+
239
+ for (const sub of SUBCOMMANDS) {
240
+ lines.push(`complete -c ai -n '__fish_use_subcommand' -a '${sub}'`);
241
+ }
242
+ lines.push("");
243
+
244
+ const SHORT_FLAG_MAP: Record<string, string> = {
245
+ "--model": "m",
246
+ "--output": "o",
247
+ "--count": "n",
248
+ "--concurrency": "p",
249
+ "--quiet": "q",
250
+ "--format": "f",
251
+ "--system": "s",
252
+ "--temperature": "t",
253
+ };
254
+
255
+ const addFlags = (sub: string, flags: string[]) => {
256
+ for (const flag of flags) {
257
+ const name = flag.replace(/^--/, "");
258
+ const short = SHORT_FLAG_MAP[flag];
259
+ const shortPart = short ? ` -s ${short}` : "";
260
+ lines.push(
261
+ `complete -c ai -n '__fish_seen_subcommand_from ${sub}'${shortPart} -l '${name}'`
262
+ );
263
+ }
264
+ };
265
+
266
+ addFlags("text", [...GLOBAL_FLAGS, ...TEXT_FLAGS]);
267
+ addFlags("image", [...GLOBAL_FLAGS, ...IMAGE_FLAGS]);
268
+ addFlags("video", [...GLOBAL_FLAGS, ...VIDEO_FLAGS]);
269
+ addFlags("models", MODEL_FLAGS);
270
+
271
+ lines.push("");
272
+ lines.push(
273
+ `complete -c ai -n '__fish_seen_subcommand_from completions' -a 'zsh bash fish'`
274
+ );
275
+ lines.push("");
276
+
277
+ const modelCompletions = ALL_MODELS.concat(MODEL_NAMES);
278
+ lines.push(
279
+ `complete -c ai -n '__fish_seen_subcommand_from text image video' -s m -l model -a '${modelCompletions.join(" ")}'`
280
+ );
281
+ lines.push(
282
+ `complete -c ai -n '__fish_seen_subcommand_from text' -s f -l format -a 'md txt'`
283
+ );
284
+ lines.push(
285
+ `complete -c ai -n '__fish_seen_subcommand_from image' -l quality -a 'standard hd'`
286
+ );
287
+ lines.push(
288
+ `complete -c ai -n '__fish_seen_subcommand_from image' -l style -a 'vivid natural'`
289
+ );
290
+ lines.push(
291
+ `complete -c ai -n '__fish_seen_subcommand_from models' -l type -a 'text image video'`
292
+ );
293
+ lines.push("");
294
+
295
+ return lines.join("\n");
296
+ }
@@ -0,0 +1,132 @@
1
+ import { generateImage, gateway } from "ai";
2
+ import type { Command } from "commander";
3
+
4
+ import { buildJobs, runJobs } from "../lib/jobs.js";
5
+ import { resolveModels } from "../lib/models.js";
6
+ import { parsePositiveInt, parseSize, parseAspectRatio } from "../lib/parse.js";
7
+ import { readStdin } from "../lib/stdin.js";
8
+
9
+ const DEFAULT_CONCURRENCY = 4;
10
+ const DEFAULT_TIMEOUT_MS = 120_000;
11
+
12
+ interface ImageOptions {
13
+ model?: string;
14
+ output?: string;
15
+ count?: string;
16
+ size?: string;
17
+ aspectRatio?: string;
18
+ quality?: string;
19
+ style?: string;
20
+ quiet?: boolean;
21
+ json?: boolean;
22
+ concurrency?: string;
23
+ preview?: boolean;
24
+ }
25
+
26
+ export function registerImageCommand(program: Command) {
27
+ program
28
+ .command("image")
29
+ .description("Generate an image from a prompt")
30
+ .argument("[prompt]", "The prompt to generate an image from")
31
+ .option(
32
+ "-m, --model <model>",
33
+ "Model ID (creator/model-name), comma-separated for multi-model"
34
+ )
35
+ .option("-o, --output <path>", "Output file path or directory")
36
+ .option("-n, --count <n>", "Number of images per model (default: 1)")
37
+ .option("--size <WxH>", "Image size (e.g. 1024x1024)")
38
+ .option("--aspect-ratio <W:H>", "Aspect ratio (e.g. 16:9)")
39
+ .option("--quality <level>", "Quality (standard, hd)")
40
+ .option("--style <style>", "Style (e.g. vivid, natural)")
41
+ .option("-q, --quiet", "Suppress progress output")
42
+ .option("--json", "Output metadata as JSON")
43
+ .option(
44
+ "--no-preview",
45
+ "Disable inline image preview in supported terminals"
46
+ )
47
+ .option(
48
+ "-p, --concurrency <n>",
49
+ `Max parallel generations (default: ${DEFAULT_CONCURRENCY})`
50
+ )
51
+ .action(async (rawPrompt: string | undefined, opts: ImageOptions) => {
52
+ const prompt = rawPrompt?.trim() || undefined;
53
+ const stdin = await readStdin();
54
+ if (!prompt && !stdin) {
55
+ process.stderr.write(
56
+ "Error: prompt is required (provide as argument or pipe via stdin)\n"
57
+ );
58
+ process.exit(1);
59
+ }
60
+ let imagePrompt: string | { images: Uint8Array[]; text?: string } =
61
+ prompt!;
62
+ if (stdin) {
63
+ imagePrompt = prompt
64
+ ? { images: [new Uint8Array(stdin)], text: prompt }
65
+ : { images: [new Uint8Array(stdin)] };
66
+ }
67
+
68
+ const models = resolveModels("image", opts.model);
69
+ const countPerModel = opts.count
70
+ ? parsePositiveInt(opts.count, "count")
71
+ : 1;
72
+ const size = opts.size ? parseSize(opts.size) : undefined;
73
+ const aspectRatio = opts.aspectRatio
74
+ ? parseAspectRatio(opts.aspectRatio)
75
+ : undefined;
76
+ const provOpts = buildProviderOptions(opts);
77
+
78
+ if (
79
+ (opts.quality || opts.style) &&
80
+ models.every((m) => !m.startsWith("openai/"))
81
+ ) {
82
+ process.stderr.write(
83
+ "Warning: --quality and --style only apply to OpenAI models\n"
84
+ );
85
+ }
86
+
87
+ const jobs = buildJobs(models, countPerModel);
88
+
89
+ const { total, failed } = await runJobs(
90
+ jobs,
91
+ async (modelId) => {
92
+ const abort = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
93
+ const result = await generateImage({
94
+ model: gateway.image(modelId),
95
+ prompt: imagePrompt,
96
+ abortSignal: abort,
97
+ n: 1,
98
+ size,
99
+ aspectRatio,
100
+ providerOptions:
101
+ Object.keys(provOpts).length > 0 ? provOpts : undefined,
102
+ });
103
+ return Buffer.from(result.image.uint8Array);
104
+ },
105
+ {
106
+ noun: "image",
107
+ format: "image",
108
+ outputPath: opts.output,
109
+ quiet: opts.quiet,
110
+ json: opts.json,
111
+ display: opts.preview,
112
+ concurrency: opts.concurrency
113
+ ? parsePositiveInt(opts.concurrency, "concurrency")
114
+ : DEFAULT_CONCURRENCY,
115
+ }
116
+ );
117
+ if (failed === total) process.exit(1);
118
+ if (failed > 0) process.exit(2);
119
+ });
120
+ }
121
+
122
+ function buildProviderOptions(
123
+ opts: ImageOptions
124
+ ): Record<string, Record<string, string>> {
125
+ const providerOptions: Record<string, Record<string, string>> = {};
126
+ if (opts.quality || opts.style) {
127
+ providerOptions.openai = {};
128
+ if (opts.quality) providerOptions.openai.quality = opts.quality;
129
+ if (opts.style) providerOptions.openai.style = opts.style;
130
+ }
131
+ return providerOptions;
132
+ }