Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions packages/ai-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,12 @@ When running in a terminal that supports the [Kitty graphics protocol](https://s

### Output Behavior

- **text**: saves to `output.md` (interactive), stdout when piped
- **image/video**: saves to file (interactive), raw binary stdout when piped
- **`-o <dir>`**: saves inside the directory with auto-generated names
Generated files are saved to `~/.ai-cli/generations/` by default, with filenames derived from the prompt (e.g. `a-sunset-a3f2.png`). Each file gets a short random hex suffix to avoid collisions.

- **Interactive (TTY)**: saves to `~/.ai-cli/generations/<slug>-<hex>.<ext>`
- **Piped (non-TTY)**: writes raw content to stdout for chaining
- **`-o <path>`**: saves to the exact path specified
- **`-o <dir>`**: saves inside the directory with smart names

### Environment Variables

Expand All @@ -117,7 +120,7 @@ When running in a terminal that supports the [Kitty graphics protocol](https://s
| `AI_CLI_TEXT_MODEL` | Default text model (overrides `openai/gpt-5.5`) |
| `AI_CLI_IMAGE_MODEL` | Default image model (overrides `openai/gpt-image-2`) |
| `AI_CLI_VIDEO_MODEL` | Default video model (overrides `bytedance/seedance-2.0`) |
| `AI_CLI_OUTPUT_DIR` | Default output directory for generated files |
| `AI_CLI_OUTPUT_DIR` | Default output directory (overrides `~/.ai-cli/generations/`) |
| `AI_CLI_PREVIEW` | Set to `1` to force inline image preview, `0` to disable |
| `NO_COLOR` | Disable ANSI color output |
| `FORCE_COLOR` | Force color output even when not a TTY |
Expand Down
1 change: 1 addition & 0 deletions packages/ai-cli/src/commands/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export function registerImageCommand(program: Command) {
noun: "image",
format: "image",
outputPath: opts.output,
prompt,
quiet: opts.quiet,
json: opts.json,
display: opts.preview,
Expand Down
1 change: 1 addition & 0 deletions packages/ai-cli/src/commands/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function registerTextCommand(program: Command) {
noun: "text",
format,
outputPath: opts.output,
prompt,
quiet: opts.quiet,
json: opts.json,
concurrency: opts.concurrency
Expand Down
1 change: 1 addition & 0 deletions packages/ai-cli/src/commands/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export function registerVideoCommand(program: Command) {
noun: "video",
format: "video",
outputPath: opts.output,
prompt,
quiet: opts.quiet,
json: opts.json,
display: opts.preview,
Expand Down
11 changes: 7 additions & 4 deletions packages/ai-cli/src/lib/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface RunJobsOptions {
noun: string;
format: OutputFormat;
outputPath?: string;
prompt?: string;
quiet?: boolean;
json?: boolean;
concurrency: number;
Expand Down Expand Up @@ -45,7 +46,8 @@ export async function runJobs(
generate: (modelId: string) => Promise<Buffer | string>,
opts: RunJobsOptions
): Promise<RunJobsResult> {
const { noun, format, outputPath, quiet, json, concurrency, display } = opts;
const { noun, format, outputPath, prompt, quiet, json, concurrency, display } =
opts;

if (jobs.length === 1) {
const { modelId } = jobs[0];
Expand All @@ -63,6 +65,7 @@ export async function runJobs(
data,
format,
outputPath,
prompt,
quiet: true,
display,
});
Expand All @@ -81,7 +84,7 @@ export async function runJobs(
};
process.stdout.write(JSON.stringify(meta, null, 2) + "\n");
} else {
await writeOutput({ data, format, outputPath, quiet, display });
await writeOutput({ data, format, outputPath, prompt, quiet, display });
}
} catch (err) {
progress.stop();
Expand Down Expand Up @@ -119,12 +122,12 @@ export async function runJobs(
try {
const data = await generate(job.modelId);
const genElapsed = Date.now() - genStart;
const suffix = `${i + 1}`;
const path = await writeOutput({
data,
format,
outputPath,
suffix,
prompt,
index: i + 1,
quiet: true,
display: false,
});
Expand Down
265 changes: 265 additions & 0 deletions packages/ai-cli/src/lib/output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { basename, join } from "path";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";

import { slugify, generateFilename, writeOutput } from "./output.js";

describe("slugify", () => {
test("lowercases and replaces non-alphanumeric with hyphens", () => {
expect(slugify("A Sunset Over Mountains")).toBe(
"a-sunset-over-mountains"
);
});

test("collapses consecutive hyphens", () => {
expect(slugify("hello world!!! test")).toBe("hello-world-test");
});

test("trims leading and trailing hyphens", () => {
expect(slugify("---hello---")).toBe("hello");
expect(slugify("!!!start end???")).toBe("start-end");
});

test("truncates at word boundary", () => {
const long = "this is a very long prompt that exceeds the maximum length allowed";
const result = slugify(long, 30);
expect(result.length).toBeLessThanOrEqual(30);
expect(result).toBe("this-is-a-very-long-prompt");
expect(result).not.toEndWith("-");
});

test("truncates without word boundary if single long word", () => {
const long = "abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklmnop";
const result = slugify(long, 40);
expect(result.length).toBe(40);
expect(result).toBe("abcdefghijklmnopqrstuvwxyz1234567890abcd");
});

test("returns empty string for empty input", () => {
expect(slugify("")).toBe("");
});

test("returns empty string for all-whitespace input", () => {
expect(slugify(" ")).toBe("");
});

test("returns empty string for all-special-chars input", () => {
expect(slugify("!@#$%^&*()")).toBe("");
});

test("handles unicode by normalizing accented characters", () => {
expect(slugify("café résumé")).toBe("cafe-resume");
});

test("preserves digits", () => {
expect(slugify("photo 1024x768")).toBe("photo-1024x768");
});

test("respects default max length of 40", () => {
const long = "a ".repeat(50).trim();
const result = slugify(long);
expect(result.length).toBeLessThanOrEqual(40);
});
});

describe("generateFilename", () => {
test("produces slug-hex.ext format with prompt", () => {
const name = generateFilename("image", "a sunset");
expect(name).toMatch(/^a-sunset-[0-9a-f]{4}\.png$/);
});

test("uses 'output' as slug when no prompt", () => {
const name = generateFilename("image");
expect(name).toMatch(/^output-[0-9a-f]{4}\.png$/);
});

test("uses correct extension for each format", () => {
expect(generateFilename("md", "test")).toMatch(/\.md$/);
expect(generateFilename("txt", "test")).toMatch(/\.txt$/);
expect(generateFilename("image", "test")).toMatch(/\.png$/);
expect(generateFilename("video", "test")).toMatch(/\.mp4$/);
});

test("produces unique names on repeated calls", () => {
const names = new Set(
Array.from({ length: 20 }, () => generateFilename("image", "same prompt"))
);
expect(names.size).toBeGreaterThan(1);
});

test("handles empty prompt like undefined", () => {
const name = generateFilename("image", "");
expect(name).toMatch(/^output-[0-9a-f]{4}\.png$/);
});

test("falls back to 'output' when prompt slugifies to empty", () => {
const name = generateFilename("image", "!!!");
expect(name).toMatch(/^output-[0-9a-f]{4}\.png$/);
});

test("appends index when provided", () => {
const name = generateFilename("image", "a sunset", 3);
expect(name).toMatch(/^a-sunset-[0-9a-f]{4}-3\.png$/);
});

test("omits index when not provided", () => {
const name = generateFilename("image", "a sunset");
expect(name).toMatch(/^a-sunset-[0-9a-f]{4}\.png$/);
});
});

describe("writeOutput", () => {
let tmpDir: string;
let savedTTY: boolean | undefined;
let savedOutputDir: string | undefined;

beforeEach(() => {
tmpDir = join(tmpdir(), `ai-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
savedTTY = process.stdout.isTTY;
savedOutputDir = process.env.AI_CLI_OUTPUT_DIR;
delete process.env.AI_CLI_OUTPUT_DIR;
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true, configurable: true });
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
Object.defineProperty(process.stdout, "isTTY", { value: savedTTY, writable: true, configurable: true });
if (savedOutputDir !== undefined) {
process.env.AI_CLI_OUTPUT_DIR = savedOutputDir;
} else {
delete process.env.AI_CLI_OUTPUT_DIR;
}
});

test("writes to explicit output directory with prompt-derived name", async () => {
const data = Buffer.from("image-data");
const result = await writeOutput({
data,
format: "image",
outputPath: tmpDir,
prompt: "a sunset",
quiet: true,
display: false,
});

expect(result).not.toBeNull();
expect(result!.startsWith(tmpDir)).toBe(true);
expect(basename(result!)).toMatch(/^a-sunset-[0-9a-f]{4}\.png$/);
expect(readFileSync(result!).toString()).toBe("image-data");
});

test("writes to explicit file path", async () => {
const filePath = join(tmpDir, "my-file.png");
const data = Buffer.from("image-data");
const result = await writeOutput({
data,
format: "image",
outputPath: filePath,
quiet: true,
display: false,
});

expect(result).toBe(filePath);
expect(readFileSync(filePath).toString()).toBe("image-data");
});

test("inserts index into explicit file path with extension", async () => {
const filePath = join(tmpDir, "foo.png");
const data = Buffer.from("data");
const result = await writeOutput({
data,
format: "image",
outputPath: filePath,
index: 2,
quiet: true,
display: false,
});

expect(result).not.toBeNull();
expect(basename(result!)).toBe("foo-2.png");
expect(existsSync(result!)).toBe(true);
});

test("inserts index into extensionless path", async () => {
const filePath = join(tmpDir, "foo");
const data = Buffer.from("data");
const result = await writeOutput({
data,
format: "image",
outputPath: filePath,
index: 3,
quiet: true,
display: false,
});

expect(result).not.toBeNull();
expect(basename(result!)).toBe("foo-3");
expect(existsSync(result!)).toBe(true);
});

test("retries on filename collision via wx flag", async () => {
const allNames = Array.from({ length: 200 }, () =>
generateFilename("image", "collision")
);
const uniqueNames = new Set(allNames);
for (const name of uniqueNames) {
writeFileSync(join(tmpDir, name), "taken");
}

const data = Buffer.from("new-data");
const result = await writeOutput({
data,
format: "image",
outputPath: tmpDir,
prompt: "collision",
quiet: true,
display: false,
});

expect(result).not.toBeNull();
expect(result!.startsWith(tmpDir)).toBe(true);
expect(readFileSync(result!).toString()).toBe("new-data");
});

test("pipes to stdout when not a TTY", async () => {
Object.defineProperty(process.stdout, "isTTY", { value: false, writable: true, configurable: true });

const chunks: Buffer[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((chunk: Uint8Array | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return true;
}) as typeof process.stdout.write;

try {
const data = Buffer.from("piped-content");
const result = await writeOutput({
data,
format: "md",
quiet: true,
display: false,
});

expect(result).toBeNull();
expect(Buffer.concat(chunks).toString()).toBe("piped-content");
} finally {
process.stdout.write = origWrite;
}
});

test("accepts string data and writes as utf-8", async () => {
const filePath = join(tmpDir, "text-output.md");
const result = await writeOutput({
data: "hello world",
format: "md",
outputPath: filePath,
quiet: true,
display: false,
});

expect(result).toBe(filePath);
expect(readFileSync(filePath, "utf-8")).toBe("hello world");
});
});
Loading
Loading