@seanmozeik/avicon 0.1.3

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/src/lib/ai.ts ADDED
@@ -0,0 +1,145 @@
1
+ import type { AiResult, GenerateResult, MultiFileResult } from "../types.js";
2
+ import type { CloudflareCredentials, AviconConfig } from "./config.js";
3
+
4
+ export const CF_MODEL = "@cf/openai/gpt-oss-120b";
5
+ export const CLAUDE_MODEL = "sonnet";
6
+
7
+ export class ValidationError extends Error {
8
+ constructor(
9
+ message: string,
10
+ public readonly raw: string,
11
+ ) {
12
+ super(message);
13
+ this.name = "ValidationError";
14
+ }
15
+ }
16
+
17
+ export function validateResponse(raw: string): AiResult {
18
+ const cleaned = raw
19
+ .trim()
20
+ .replace(/^```(?:json)?\n?/, "")
21
+ .replace(/\n?```$/, "");
22
+
23
+ let parsed: unknown;
24
+ try {
25
+ parsed = JSON.parse(cleaned);
26
+ } catch {
27
+ throw new ValidationError("Invalid JSON response from AI", raw);
28
+ }
29
+
30
+ const obj = parsed as {
31
+ multi_file?: unknown;
32
+ glob?: unknown;
33
+ commands?: unknown;
34
+ output_template?: unknown;
35
+ explanation?: unknown;
36
+ };
37
+
38
+ if (obj.multi_file === true) {
39
+ if (
40
+ !Array.isArray(obj.glob) ||
41
+ !obj.glob.every((g: unknown) => typeof g === "string") ||
42
+ !Array.isArray(obj.commands) ||
43
+ !obj.commands.every((c: unknown) => typeof c === "string") ||
44
+ typeof obj.output_template !== "string" ||
45
+ typeof obj.explanation !== "string"
46
+ ) {
47
+ throw new ValidationError(
48
+ "Multi-file response missing required fields: glob (string[]), commands (string[]), output_template (string), explanation (string)",
49
+ raw,
50
+ );
51
+ }
52
+ return {
53
+ multi_file: true,
54
+ glob: obj.glob as string[],
55
+ commands: obj.commands as string[],
56
+ output_template: obj.output_template,
57
+ explanation: obj.explanation,
58
+ } satisfies MultiFileResult;
59
+ }
60
+
61
+ if (
62
+ !Array.isArray(obj.commands) ||
63
+ !obj.commands.every((c: unknown) => typeof c === "string") ||
64
+ typeof obj.explanation !== "string"
65
+ ) {
66
+ throw new ValidationError(
67
+ "Response missing required fields: commands (string[]) and explanation (string)",
68
+ raw,
69
+ );
70
+ }
71
+
72
+ return {
73
+ commands: obj.commands as string[],
74
+ explanation: obj.explanation,
75
+ } satisfies GenerateResult;
76
+ }
77
+
78
+ export async function generateWithCloudflare(
79
+ systemPrompt: string,
80
+ userPrompt: string,
81
+ credentials: CloudflareCredentials,
82
+ ): Promise<AiResult> {
83
+ const response = await fetch(
84
+ `https://api.cloudflare.com/client/v4/accounts/${credentials.accountId}/ai/v1/chat/completions`,
85
+ {
86
+ method: "POST",
87
+ headers: {
88
+ Authorization: `Bearer ${credentials.apiToken}`,
89
+ "Content-Type": "application/json",
90
+ },
91
+ body: JSON.stringify({
92
+ model: CF_MODEL,
93
+ messages: [
94
+ { role: "system", content: systemPrompt },
95
+ { role: "user", content: userPrompt },
96
+ ],
97
+ response_format: { type: "json_object" },
98
+ max_tokens: 2048,
99
+ }),
100
+ },
101
+ );
102
+
103
+ if (!response.ok) {
104
+ const error = await response.text();
105
+ throw new Error(`Cloudflare API error ${response.status}: ${error}`);
106
+ }
107
+
108
+ const data = (await response.json()) as {
109
+ choices?: { message?: { content?: string } }[];
110
+ };
111
+
112
+ const raw = data.choices?.[0]?.message?.content ?? "";
113
+ return validateResponse(raw);
114
+ }
115
+
116
+ export async function generateWithClaude(
117
+ systemPrompt: string,
118
+ userPrompt: string,
119
+ ): Promise<AiResult> {
120
+ const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
121
+ const proc = Bun.spawn(
122
+ ["claude", "--model", CLAUDE_MODEL, "-p", combinedPrompt],
123
+ {
124
+ stdout: "pipe",
125
+ },
126
+ );
127
+ const raw = (await new Response(proc.stdout).text()).trim();
128
+ return validateResponse(raw);
129
+ }
130
+
131
+ export async function generate(
132
+ systemPrompt: string,
133
+ userPrompt: string,
134
+ config: AviconConfig,
135
+ ): Promise<AiResult> {
136
+ if (config.defaultProvider === "cloudflare") {
137
+ if (!config.cloudflare) {
138
+ throw new Error(
139
+ "Cloudflare credentials not configured. Run: avicon setup",
140
+ );
141
+ }
142
+ return generateWithCloudflare(systemPrompt, userPrompt, config.cloudflare);
143
+ }
144
+ return generateWithClaude(systemPrompt, userPrompt);
145
+ }
@@ -0,0 +1,40 @@
1
+ import { $ } from "bun";
2
+
3
+ /**
4
+ * Copy text to clipboard (cross-platform)
5
+ * Returns true if successful, false if no clipboard tool available
6
+ */
7
+ export async function copyToClipboard(text: string): Promise<boolean> {
8
+ // macOS
9
+ if (process.platform === "darwin") {
10
+ const proc = Bun.spawn(["pbcopy"], { stdin: "pipe" });
11
+ proc.stdin.write(text);
12
+ proc.stdin.end();
13
+ await proc.exited;
14
+ return true;
15
+ }
16
+
17
+ // Linux: try available clipboard tools in order of preference
18
+ const tools: string[][] = [
19
+ ["xclip", "-selection", "clipboard"],
20
+ ["xsel", "--clipboard", "--input"],
21
+ ["wl-copy"], // Wayland
22
+ ];
23
+
24
+ for (const cmd of tools) {
25
+ try {
26
+ const which = await $`which ${cmd[0]}`.quiet();
27
+ if (which.exitCode === 0) {
28
+ const proc = Bun.spawn(cmd, { stdin: "pipe" });
29
+ proc.stdin.write(text);
30
+ proc.stdin.end();
31
+ await proc.exited;
32
+ return true;
33
+ }
34
+ } catch {
35
+ // Tool not found, try next
36
+ }
37
+ }
38
+
39
+ return false;
40
+ }
@@ -0,0 +1,83 @@
1
+ import { CONFIG_KEY, SECRETS_SERVICE } from "./secrets.js";
2
+
3
+ export type Provider = "cloudflare" | "claude";
4
+
5
+ export interface CloudflareCredentials {
6
+ accountId: string;
7
+ apiToken: string;
8
+ }
9
+
10
+ export interface AviconConfig {
11
+ defaultProvider: Provider;
12
+ cloudflare?: CloudflareCredentials;
13
+ }
14
+
15
+ // Three-state cache: undefined = not yet loaded, null = nothing found, object = valid config
16
+ let _cache: AviconConfig | null | undefined;
17
+
18
+ export async function getConfig(): Promise<AviconConfig | null> {
19
+ if (_cache !== undefined) return _cache;
20
+
21
+ // Check env var first
22
+ const envVal = process.env[CONFIG_KEY];
23
+ if (envVal) {
24
+ try {
25
+ _cache = JSON.parse(envVal) as AviconConfig;
26
+ return _cache;
27
+ } catch {
28
+ // Invalid JSON in env var — fall through
29
+ }
30
+ }
31
+
32
+ // Try Bun.secrets
33
+ try {
34
+ const val = await Bun.secrets.get({
35
+ service: SECRETS_SERVICE,
36
+ name: CONFIG_KEY,
37
+ });
38
+ if (val) {
39
+ _cache = JSON.parse(val) as AviconConfig;
40
+ return _cache;
41
+ }
42
+ } catch {
43
+ // Secret store unavailable — fall through
44
+ }
45
+
46
+ _cache = null;
47
+ return null;
48
+ }
49
+
50
+ export async function setConfig(config: AviconConfig): Promise<void> {
51
+ try {
52
+ await Bun.secrets.set({
53
+ service: SECRETS_SERVICE,
54
+ name: CONFIG_KEY,
55
+ value: JSON.stringify(config),
56
+ });
57
+ } catch (err) {
58
+ const msg = err instanceof Error ? err.message : String(err);
59
+ if (
60
+ msg.toLowerCase().includes("libsecret") ||
61
+ msg.toLowerCase().includes("secret service")
62
+ ) {
63
+ throw new Error(
64
+ `Failed to store config in keychain: ${msg}\n` +
65
+ `Install libsecret:\n` +
66
+ ` Ubuntu/Debian: sudo apt install libsecret-1-0 libsecret-tools\n` +
67
+ ` Fedora: sudo dnf install libsecret\n` +
68
+ ` Arch: sudo pacman -S libsecret`,
69
+ );
70
+ }
71
+ throw err;
72
+ }
73
+ _cache = config;
74
+ }
75
+
76
+ export async function deleteConfig(): Promise<void> {
77
+ _cache = undefined;
78
+ try {
79
+ await Bun.secrets.delete({ service: SECRETS_SERVICE, name: CONFIG_KEY });
80
+ } catch {
81
+ // Not found — no-op
82
+ }
83
+ }
@@ -0,0 +1,49 @@
1
+ import * as path from "node:path";
2
+
3
+ export async function expandGlob(patterns: string[]): Promise<string[]> {
4
+ const seen = new Set<string>();
5
+ for (const pattern of patterns) {
6
+ const glob = new Bun.Glob(pattern);
7
+ for await (const file of glob.scan(".")) {
8
+ seen.add(file);
9
+ }
10
+ }
11
+ return [...seen].sort();
12
+ }
13
+
14
+ export function resolveVars(
15
+ template: string,
16
+ vars: Record<string, string>,
17
+ ): string {
18
+ return template.replace(
19
+ /\{\{(\w+)\}\}/g,
20
+ (_, key) => vars[key] ?? `{{${key}}}`,
21
+ );
22
+ }
23
+
24
+ export function buildFileCommands(
25
+ file: string,
26
+ commandTemplates: string[],
27
+ outputTemplate: string,
28
+ ): string[] {
29
+ const dir = path.dirname(file);
30
+ const stem = path.basename(file, path.extname(file));
31
+ const input = file;
32
+
33
+ const baseVars: Record<string, string> = { input, stem, dir, output: "" };
34
+ const output = resolveVars(outputTemplate, baseVars);
35
+ const vars: Record<string, string> = { input, stem, dir, output };
36
+
37
+ return commandTemplates.map((tpl) => resolveVars(tpl, vars));
38
+ }
39
+
40
+ export function buildBatchCommands(
41
+ files: string[],
42
+ commandTemplates: string[],
43
+ outputTemplate: string,
44
+ ): Array<{ file: string; commands: string[] }> {
45
+ return files.map((file) => ({
46
+ file,
47
+ commands: buildFileCommands(file, commandTemplates, outputTemplate),
48
+ }));
49
+ }
@@ -0,0 +1,102 @@
1
+ import type { ToolContext } from "../types.js";
2
+
3
+ export function buildSystemPrompt(ctx: ToolContext): string {
4
+ const lines: string[] = [];
5
+
6
+ lines.push("## Available Tools");
7
+
8
+ if (ctx.ffmpeg.installed) {
9
+ lines.push(`FFmpeg ${ctx.ffmpeg.version ?? "unknown"}`);
10
+ lines.push(` Codecs: ${ctx.ffmpeg.codecs.join(", ") || "none"}`);
11
+ lines.push(` Filters: ${ctx.ffmpeg.filters.join(", ") || "none"}`);
12
+ lines.push(
13
+ ` Bitstream filters: ${ctx.ffmpeg.bitstreamFilters.join(", ") || "none"}`,
14
+ );
15
+ lines.push(` Formats: ${ctx.ffmpeg.formats.join(", ") || "none"}`);
16
+ } else {
17
+ lines.push("FFmpeg: NOT installed — do not generate ffmpeg commands");
18
+ }
19
+
20
+ if (ctx.magick.installed) {
21
+ lines.push(`magick ${ctx.magick.version ?? "unknown"}`);
22
+ lines.push(` Formats: ${ctx.magick.formats.join(", ") || "none"}`);
23
+ } else {
24
+ lines.push("magick: NOT installed — do not generate magick commands");
25
+ }
26
+
27
+ const environment = lines.join("\n");
28
+
29
+ const rules = `## FFmpeg encoding defaults
30
+ Apply these unless the user explicitly requests otherwise:
31
+
32
+ Video quality — always use constant-quality mode, never omit a quality flag or use -b:v alone:
33
+ libx264: -crf 23 -preset slow
34
+ libx265: -crf 28 -preset slow
35
+ libsvtav1: -crf 35 -preset 6
36
+ libvpx-vp9: -crf 33 -b:v 0
37
+ h264_videotoolbox / hevc_videotoolbox: -q:v 65 (scale 1–100, higher=better quality)
38
+ h264_nvenc / hevc_nvenc / av1_nvenc: -rc vbr -cq 28
39
+ h264_vaapi / hevc_vaapi / av1_vaapi: -qp 28
40
+
41
+ Pixel format — always add -pix_fmt yuv420p for H.264, H.265, VP9, AV1 output (required for broad device compatibility)
42
+
43
+ MP4 output — always add -movflags +faststart (enables streaming before full download)
44
+
45
+ Metadata — always add -map_metadata 0 to preserve source metadata (timestamps, GPS, rotation tags); omit for GIF or other formats that don't support metadata
46
+
47
+ Audio — when transcoding audio, default to:
48
+ -c:a aac -b:a 192k -ar 48000 -ac 2 (for MP4/MOV output)
49
+ -c:a libopus -b:a 128k -ar 48000 (for WebM/MKV output)
50
+ -c:a flac (for lossless)
51
+ Honor any sample rate, bitrate, or channel count the user specifies.
52
+ Use -c:a copy only when the input audio codec already matches the output container
53
+
54
+ Scaling — when resizing, always use scale=-2:<height> (e.g. scale=-2:720) to preserve aspect ratio with even dimensions; never use -1 (causes encoder errors on odd dimensions)
55
+
56
+ Subtitles / attachments — use -sn -dn to explicitly drop subtitle and data streams unless the user asks to keep them
57
+
58
+ GIF output — always use the two-step palette pipeline, never a single-command conversion:
59
+ Step 1 (palette): ffmpeg -hide_banner -nostdin -i <input> -vf "fps=<fps>,scale=-2:<height>:flags=lanczos,palettegen=stats_mode=diff" /tmp/<stem>_palette.png
60
+ Step 2 (encode): ffmpeg -hide_banner -nostdin -i <input> -i /tmp/<stem>_palette.png -lavfi "fps=<fps>,scale=-2:<height>:flags=lanczos [x];[x][1:v] paletteuse=dither=bayer:bayer_scale=5" <output>.gif
61
+ Default fps=15, height=480 unless the user specifies otherwise.
62
+
63
+ ## Container/codec compatibility (common cases — not exhaustive, use judgement for edge cases)
64
+ - .mp4 / .mov → video: H.264, H.265, AV1 audio: AAC, MP3 (not Opus or FLAC)
65
+ - .webm → video: VP8, VP9, AV1 audio: Opus, Vorbis (not AAC)
66
+ - .mkv → accepts almost any codec; good choice when unsure
67
+ - .gif → no audio stream; use palette pipeline above
68
+ - .mp3 → audio only; use libmp3lame
69
+ - .flac / .wav → lossless audio only; no video
70
+
71
+ ## Rules
72
+ - explanation: plain prose only — no shell syntax, no backticks, no code
73
+ - BEFORE generating any command: choose the optimal codec and encoder from the available codecs list above for the target format, preferring hardware-accelerated encoders (e.g. h264_videotoolbox, hevc_videotoolbox) over software ones, and modern codecs (av1, hevc) over older ones when quality/efficiency matters
74
+ - NEVER rely on FFmpeg auto-selection — always specify -c:v for video output and -c:a for audio output explicitly
75
+ - If a required FFmpeg encoder is not available, use magick if the format is in its format list
76
+ - Prefer non-destructive output: append _converted to output filenames, use -n flag to avoid overwriting
77
+
78
+ SINGLE-FILE MODE: Use when operating on specific named file(s) or when the user provides explicit filenames.
79
+ Schema: { "commands": string[], "explanation": string }
80
+ - commands: concrete shell strings, no placeholders, no &&, no loops; may have multiple steps (e.g. transcode → encode)
81
+ - always include -hide_banner -nostdin in every ffmpeg command
82
+ - If neither tool can handle the task, return { "commands": [], "explanation": "<reason why it cannot be done with available tools>" }
83
+
84
+ BATCH MODE: Use when user wants to process all files matching a pattern (e.g. "all mp4s", "every image", "all files in this folder").
85
+ Schema: { "multi_file": true, "glob": string[], "commands": string[], "output_template": string, "explanation": string }
86
+ - glob: array of glob patterns relative to cwd (e.g. ["*.mp4"])
87
+ - commands: template strings — each may use {{input}}, {{output}}, {{stem}}, {{dir}}; always include -hide_banner -nostdin in every ffmpeg command
88
+ {{input}} — path to the input file
89
+ {{output}} — resolved output path (from output_template)
90
+ {{stem}} — filename without extension (e.g. "video" from "video.mp4")
91
+ {{dir}} — directory of the input file
92
+ - output_template: e.g. "{{dir}}/{{stem}}_converted.mp4" — use {{dir}}/{{stem}} as vars, literal output extension
93
+ - Multiple commands per file are fine (e.g. intermediate files using /tmp/{{stem}}_raw.mkv)
94
+
95
+ IMPORTANT: Reply with ONLY the JSON object — no markdown fences, no extra text`;
96
+
97
+ return [environment, rules].join("\n\n");
98
+ }
99
+
100
+ export function buildUserPrompt(request: string): string {
101
+ return request;
102
+ }
package/src/lib/run.ts ADDED
@@ -0,0 +1,35 @@
1
+ export type RunCallbacks = {
2
+ onBefore?: (cmd: string, index: number, total: number) => void;
3
+ onSuccess?: () => void;
4
+ onError?: (cmd: string, exitCode: number) => void;
5
+ };
6
+
7
+ /**
8
+ * Run commands in series via sh -c, streaming stdout/stderr to the terminal.
9
+ * Returns true if all commands exit 0, false on first non-zero exit.
10
+ */
11
+ export async function runCommands(
12
+ commands: string[],
13
+ callbacks: RunCallbacks = {},
14
+ ): Promise<boolean> {
15
+ const total = commands.length;
16
+
17
+ for (const [i, cmd] of commands.entries()) {
18
+ callbacks.onBefore?.(cmd, i, total);
19
+
20
+ const proc = Bun.spawn(["sh", "-c", cmd], {
21
+ stdout: "inherit",
22
+ stderr: "inherit",
23
+ });
24
+
25
+ await proc.exited;
26
+
27
+ if (proc.exitCode !== 0) {
28
+ callbacks.onError?.(cmd, proc.exitCode ?? 1);
29
+ return false;
30
+ }
31
+ }
32
+
33
+ callbacks.onSuccess?.();
34
+ return true;
35
+ }
@@ -0,0 +1,2 @@
1
+ export const SECRETS_SERVICE = "com.avicon.cli";
2
+ export const CONFIG_KEY = "AVICON_CONFIG";
@@ -0,0 +1,112 @@
1
+ import type { ToolContext } from "../types.js";
2
+
3
+ let cached: ToolContext | undefined;
4
+
5
+ async function run(cmd: string[]): Promise<string> {
6
+ try {
7
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
8
+ return (await new Response(proc.stdout).text()).trim();
9
+ } catch {
10
+ return "";
11
+ }
12
+ }
13
+
14
+ async function probeFfmpeg(): Promise<ToolContext["ffmpeg"]> {
15
+ const versionOut = await run(["ffmpeg", "-version"]);
16
+ if (!versionOut)
17
+ return {
18
+ installed: false,
19
+ codecs: [],
20
+ filters: [],
21
+ bitstreamFilters: [],
22
+ formats: [],
23
+ };
24
+
25
+ const versionMatch = versionOut.split("\n")[0]?.match(/ffmpeg version (\S+)/);
26
+ const version = versionMatch?.[1];
27
+
28
+ const [codecsOut, filtersOut, bsfsOut, formatsOut] = await Promise.all([
29
+ run(["ffmpeg", "-codecs"]),
30
+ run(["ffmpeg", "-filters"]),
31
+ run(["ffmpeg", "-bsfs"]),
32
+ run(["ffmpeg", "-formats"]),
33
+ ]);
34
+
35
+ // Codecs: " DEV.LS h264 H.264 ... (encoders: libx264 h264_videotoolbox)"
36
+ // Only keep entries where position 2 is 'E' (has at least one encoder).
37
+ // When an (encoders: ...) list is present, expose those names directly
38
+ // (they are the actual encoder IDs to pass to -c:v/-c:a).
39
+ // Otherwise the codec name itself is the encoder.
40
+ const codecs = codecsOut
41
+ .split("\n")
42
+ .filter((l) => /^ [D.]E[VASDT][I.][L.][S.] \S/.test(l))
43
+ .flatMap((l) => {
44
+ const rest = l.slice(8);
45
+ const name = rest.trim().split(/\s+/)[0] ?? "";
46
+ const encoderMatch = rest.match(/\(encoders: ([^)]+)\)/);
47
+ return encoderMatch?.[1] ? encoderMatch[1].trim().split(/\s+/) : [name];
48
+ })
49
+ .filter(Boolean);
50
+
51
+ // Filters: " TS scale V->V description"
52
+ const filters = filtersOut
53
+ .split("\n")
54
+ .filter((l) => /^ [T.][S.] \w/.test(l))
55
+ .map((l) => l.slice(4).trim().split(/\s+/)[0] ?? "")
56
+ .filter(Boolean);
57
+
58
+ // Bitstream filters: simple list after header
59
+ const bsfLines = bsfsOut.split("\n");
60
+ const bsfStart = bsfLines.findIndex((l) => l.includes("Bitstream filters:"));
61
+ const bitstreamFilters =
62
+ bsfStart >= 0
63
+ ? bsfLines
64
+ .slice(bsfStart + 1)
65
+ .map((l) => l.trim())
66
+ .filter(Boolean)
67
+ : [];
68
+
69
+ // Formats: " DE avi AVI (Audio Video Interleaved)"
70
+ const formats = formatsOut
71
+ .split("\n")
72
+ .filter((l) => /^ [D ][E ][d ] \S/.test(l))
73
+ .map((l) => l.slice(5).trim().split(/\s+/)[0] ?? "")
74
+ .filter(Boolean);
75
+
76
+ return {
77
+ installed: true,
78
+ version,
79
+ codecs,
80
+ filters,
81
+ bitstreamFilters,
82
+ formats,
83
+ };
84
+ }
85
+
86
+ async function probeMagick(): Promise<ToolContext["magick"]> {
87
+ const versionOut = await run(["magick", "-version"]);
88
+ if (!versionOut) return { installed: false, formats: [] };
89
+
90
+ const versionMatch = versionOut
91
+ .split("\n")[0]
92
+ ?.match(/Version: ImageMagick (\S+)/);
93
+ const version = versionMatch?.[1];
94
+
95
+ const formatsOut = await run(["magick", "-list", "format"]);
96
+
97
+ const formats = formatsOut
98
+ .split("\n")
99
+ .filter((l) => /^\s+[A-Z0-9]+\*?\s/.test(l))
100
+ .map((l) => l.trim().split(/\s+/)[0]?.replace("*", "") ?? "")
101
+ .filter(Boolean);
102
+
103
+ return { installed: true, version, formats };
104
+ }
105
+
106
+ export async function detectContext(): Promise<ToolContext> {
107
+ if (cached) return cached;
108
+
109
+ const [ffmpeg, magick] = await Promise.all([probeFfmpeg(), probeMagick()]);
110
+ cached = { ffmpeg, magick };
111
+ return cached;
112
+ }
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ export interface GenerateResult {
2
+ commands: string[];
3
+ explanation: string;
4
+ }
5
+
6
+ export interface MultiFileResult {
7
+ multi_file: true;
8
+ glob: string[];
9
+ commands: string[];
10
+ output_template: string;
11
+ explanation: string;
12
+ }
13
+
14
+ export type AiResult = GenerateResult | MultiFileResult;
15
+
16
+ export interface ToolContext {
17
+ ffmpeg: {
18
+ installed: boolean;
19
+ version?: string;
20
+ codecs: string[];
21
+ filters: string[];
22
+ bitstreamFilters: string[];
23
+ formats: string[];
24
+ };
25
+ magick: {
26
+ installed: boolean;
27
+ version?: string;
28
+ formats: string[];
29
+ };
30
+ }
@@ -0,0 +1,24 @@
1
+ import gradient from "gradient-string";
2
+ import { gradientColors } from "./theme.js";
3
+
4
+ const bannerGradient = gradient([...gradientColors.banner]);
5
+
6
+ const BANNER = `
7
+ d8,
8
+ \`8P
9
+
10
+ d888b8b ?88 d8P 88b d8888b d8888b 88bd88b
11
+ d8P' ?88 d88 d8P' 88Pd8P' \`Pd8P' ?88 88P' ?8b
12
+ 88b ,88b ?8b ,88' d88 88b 88b d88 d88 88P
13
+ \`?88P'\`88b\`?888P' d88' \`?888P'\`?8888P'd88' 88b
14
+
15
+
16
+
17
+ `;
18
+
19
+ /**
20
+ * Display the ASCII art banner with gradient colors
21
+ */
22
+ export function showBanner(): void {
23
+ console.log(bannerGradient(BANNER));
24
+ }