@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/README.md +207 -0
- package/package.json +49 -0
- package/src/index.ts +781 -0
- package/src/lib/ai.ts +145 -0
- package/src/lib/clipboard.ts +40 -0
- package/src/lib/config.ts +83 -0
- package/src/lib/multi.ts +49 -0
- package/src/lib/prompt.ts +102 -0
- package/src/lib/run.ts +35 -0
- package/src/lib/secrets.ts +2 -0
- package/src/lib/tools.ts +112 -0
- package/src/types.ts +30 -0
- package/src/ui/banner.ts +24 -0
- package/src/ui/theme.ts +162 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
4
|
+
import boxen from "boxen";
|
|
5
|
+
import { generate, ValidationError } from "./lib/ai.js";
|
|
6
|
+
import { copyToClipboard } from "./lib/clipboard.js";
|
|
7
|
+
import type { Provider, AviconConfig } from "./lib/config.js";
|
|
8
|
+
import { deleteConfig, getConfig, setConfig } from "./lib/config.js";
|
|
9
|
+
import { buildBatchCommands, expandGlob } from "./lib/multi.js";
|
|
10
|
+
import { buildSystemPrompt, buildUserPrompt } from "./lib/prompt.js";
|
|
11
|
+
import { runCommands } from "./lib/run.js";
|
|
12
|
+
import { detectContext } from "./lib/tools.js";
|
|
13
|
+
import type { AiResult, GenerateResult, MultiFileResult } from "./types.js";
|
|
14
|
+
import { showBanner } from "./ui/banner.js";
|
|
15
|
+
import { boxColors, frappe, theme } from "./ui/theme.js";
|
|
16
|
+
|
|
17
|
+
// ── Arg parsing ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
|
|
21
|
+
function popFlag(flags: string[]): string | undefined {
|
|
22
|
+
for (const flag of flags) {
|
|
23
|
+
const i = args.indexOf(flag);
|
|
24
|
+
if (i !== -1) {
|
|
25
|
+
args.splice(i, 1);
|
|
26
|
+
return flag;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function popFlagValue(flag: string): string | undefined {
|
|
33
|
+
const i = args.indexOf(flag);
|
|
34
|
+
if (i !== -1 && i + 1 < args.length) {
|
|
35
|
+
const val = args[i + 1];
|
|
36
|
+
args.splice(i, 2);
|
|
37
|
+
return val;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const helpFlag = popFlag(["--help", "-h"]);
|
|
43
|
+
const versionFlag = popFlag(["--version", "-v"]);
|
|
44
|
+
const providerOverride = popFlagValue("--provider") as Provider | undefined;
|
|
45
|
+
|
|
46
|
+
// ── Help & Version ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
if (versionFlag) {
|
|
49
|
+
const pkg = (await import("../package.json")) as { version: string };
|
|
50
|
+
console.log(`avicon v${pkg.version}`);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (helpFlag) {
|
|
55
|
+
showBanner();
|
|
56
|
+
console.log(
|
|
57
|
+
[
|
|
58
|
+
"",
|
|
59
|
+
` ${theme.heading("Usage:")} avicon <request> [--provider cloudflare|claude]`,
|
|
60
|
+
"",
|
|
61
|
+
` ${theme.heading("Subcommands:")}`,
|
|
62
|
+
` ${frappe.sky("setup")} Configure AI provider credentials`,
|
|
63
|
+
` ${frappe.sky("teardown")} Remove saved credentials`,
|
|
64
|
+
"",
|
|
65
|
+
` ${theme.heading("Flags:")}`,
|
|
66
|
+
` ${frappe.sky("--provider")} Override provider for this invocation`,
|
|
67
|
+
` ${frappe.sky("--help")} Show this help`,
|
|
68
|
+
` ${frappe.sky("--version")} Print version`,
|
|
69
|
+
"",
|
|
70
|
+
` ${theme.heading("Examples:")}`,
|
|
71
|
+
` avicon "convert video.mp4 to gif at 15fps"`,
|
|
72
|
+
` avicon "resize all jpgs in this folder to 800px wide"`,
|
|
73
|
+
` avicon "extract audio from interview.mov as flac" --provider claude`,
|
|
74
|
+
"",
|
|
75
|
+
].join("\n"),
|
|
76
|
+
);
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Subcommands ───────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
async function setupCloudflare(): Promise<void> {
|
|
83
|
+
const accountId = await p.text({
|
|
84
|
+
message: "Cloudflare Account ID:",
|
|
85
|
+
validate: (v) => (v?.trim() ? undefined : "Required"),
|
|
86
|
+
});
|
|
87
|
+
if (p.isCancel(accountId)) {
|
|
88
|
+
p.cancel("Setup cancelled.");
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const apiToken = await p.password({
|
|
93
|
+
message: "Cloudflare AI API token:",
|
|
94
|
+
validate: (v) => (v?.trim() ? undefined : "Required"),
|
|
95
|
+
});
|
|
96
|
+
if (p.isCancel(apiToken)) {
|
|
97
|
+
p.cancel("Setup cancelled.");
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const config: AviconConfig = {
|
|
102
|
+
defaultProvider: "cloudflare",
|
|
103
|
+
cloudflare: {
|
|
104
|
+
accountId: (accountId as string).trim(),
|
|
105
|
+
apiToken: (apiToken as string).trim(),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
await setConfig(config);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
p.log.error(err instanceof Error ? err.message : String(err));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
p.outro("Cloudflare AI configured and saved.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function runSetup(): Promise<void> {
|
|
118
|
+
showBanner();
|
|
119
|
+
p.intro("Configure avicon AI provider");
|
|
120
|
+
|
|
121
|
+
const provider = await p.select<Provider>({
|
|
122
|
+
message: "Which AI provider?",
|
|
123
|
+
options: [
|
|
124
|
+
{
|
|
125
|
+
value: "cloudflare" as Provider,
|
|
126
|
+
label: "Cloudflare AI",
|
|
127
|
+
hint: "requires Account ID + API token",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
value: "claude" as Provider,
|
|
131
|
+
label: "Claude Code CLI",
|
|
132
|
+
hint: "requires claude CLI installed",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (p.isCancel(provider)) {
|
|
138
|
+
p.cancel("Setup cancelled.");
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if ((provider as Provider) === "cloudflare") {
|
|
143
|
+
await setupCloudflare();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// claude — verify CLI is available
|
|
148
|
+
const proc = Bun.spawn(["which", "claude"], {
|
|
149
|
+
stdout: "pipe",
|
|
150
|
+
stderr: "pipe",
|
|
151
|
+
});
|
|
152
|
+
await proc.exited;
|
|
153
|
+
if (proc.exitCode !== 0) {
|
|
154
|
+
p.log.error(
|
|
155
|
+
"claude CLI not found. Install it from https://claude.ai/code and re-run setup.",
|
|
156
|
+
);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const config: AviconConfig = { defaultProvider: "claude" };
|
|
161
|
+
try {
|
|
162
|
+
await setConfig(config);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
p.log.error(err instanceof Error ? err.message : String(err));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
p.outro("Claude Code CLI configured and saved.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function runTeardown(): Promise<void> {
|
|
171
|
+
showBanner();
|
|
172
|
+
|
|
173
|
+
const confirm = await p.confirm({
|
|
174
|
+
message: "Delete avicon config from keychain?",
|
|
175
|
+
initialValue: false,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
179
|
+
p.outro("Teardown cancelled.");
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await deleteConfig();
|
|
184
|
+
p.outro("Config deleted.");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Tool summary line ─────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
type ToolCtx = Awaited<ReturnType<typeof detectContext>>;
|
|
190
|
+
|
|
191
|
+
function renderToolSummary(ctx: ToolCtx): string {
|
|
192
|
+
const parts: string[] = [];
|
|
193
|
+
|
|
194
|
+
if (ctx.ffmpeg.installed) {
|
|
195
|
+
const ver = ctx.ffmpeg.version ?? "?";
|
|
196
|
+
const cod = ctx.ffmpeg.codecs.length;
|
|
197
|
+
const fil = ctx.ffmpeg.filters.length;
|
|
198
|
+
const fmt = ctx.ffmpeg.formats.length;
|
|
199
|
+
parts.push(
|
|
200
|
+
theme.muted(
|
|
201
|
+
`FFmpeg ${ver} (${cod} codecs · ${fil} filters · ${fmt} formats)`,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
parts.push(frappe.yellow("FFmpeg not found"));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (ctx.magick.installed) {
|
|
209
|
+
const ver = ctx.magick.version ?? "?";
|
|
210
|
+
const fmt = ctx.magick.formats.length;
|
|
211
|
+
parts.push(theme.muted(`magick ${ver} (${fmt} formats)`));
|
|
212
|
+
} else {
|
|
213
|
+
parts.push(frappe.yellow("magick not found"));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return parts.join(theme.muted(" · "));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Display panels ────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function renderPanels(result: GenerateResult): void {
|
|
222
|
+
const explanationBox = boxen(result.explanation, {
|
|
223
|
+
borderColor: boxColors.primary,
|
|
224
|
+
borderStyle: "round",
|
|
225
|
+
padding: { top: 1, bottom: 1, left: 2, right: 2 },
|
|
226
|
+
title: "What this does",
|
|
227
|
+
titleAlignment: "center",
|
|
228
|
+
});
|
|
229
|
+
console.log(`\n${explanationBox}`);
|
|
230
|
+
|
|
231
|
+
if (result.commands.length > 0) {
|
|
232
|
+
const numberedCmds = result.commands
|
|
233
|
+
.map((cmd, i) => `${frappe.sky(`[${i + 1}]`)} ${cmd}`)
|
|
234
|
+
.join("\n");
|
|
235
|
+
|
|
236
|
+
const commandsBox = boxen(numberedCmds, {
|
|
237
|
+
borderColor: boxColors.default,
|
|
238
|
+
dimBorder: true,
|
|
239
|
+
borderStyle: "round",
|
|
240
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
241
|
+
title: "Commands",
|
|
242
|
+
titleAlignment: "left",
|
|
243
|
+
});
|
|
244
|
+
console.log(`\n${commandsBox}\n`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Batch panels ──────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
function renderBatchPanels(
|
|
251
|
+
result: MultiFileResult,
|
|
252
|
+
fileGroups: Array<{ file: string; commands: string[] }>,
|
|
253
|
+
): void {
|
|
254
|
+
const explanationBox = boxen(result.explanation, {
|
|
255
|
+
borderColor: boxColors.primary,
|
|
256
|
+
borderStyle: "round",
|
|
257
|
+
padding: { top: 1, bottom: 1, left: 2, right: 2 },
|
|
258
|
+
title: "What this does",
|
|
259
|
+
titleAlignment: "center",
|
|
260
|
+
});
|
|
261
|
+
console.log(`\n${explanationBox}`);
|
|
262
|
+
|
|
263
|
+
const stepCount = result.commands.length;
|
|
264
|
+
const fileCount = fileGroups.length;
|
|
265
|
+
const PREVIEW_MAX = 3;
|
|
266
|
+
const preview = fileGroups.slice(0, PREVIEW_MAX);
|
|
267
|
+
const rest = fileGroups.length - preview.length;
|
|
268
|
+
|
|
269
|
+
const longestInput = Math.max(...preview.map((g) => g.file.length));
|
|
270
|
+
const rows = preview.map(({ file, commands }) => {
|
|
271
|
+
const output = commands[commands.length - 1]?.match(/\S+$/) ?? ["?"];
|
|
272
|
+
const outputFile = output[0];
|
|
273
|
+
const pad = " ".repeat(longestInput - file.length + 2);
|
|
274
|
+
return ` ${frappe.sky(file)}${pad}→ ${outputFile}`;
|
|
275
|
+
});
|
|
276
|
+
if (rest > 0) {
|
|
277
|
+
rows.push(` ${theme.muted(`…and ${rest} more`)}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const previewBody = [
|
|
281
|
+
`${frappe.sky(String(fileCount))} file${fileCount !== 1 ? "s" : ""} matched ${result.glob.join(", ")} (${stepCount} command${stepCount !== 1 ? "s" : ""} each):`,
|
|
282
|
+
...rows,
|
|
283
|
+
].join("\n");
|
|
284
|
+
|
|
285
|
+
const previewBox = boxen(previewBody, {
|
|
286
|
+
borderColor: boxColors.default,
|
|
287
|
+
dimBorder: true,
|
|
288
|
+
borderStyle: "round",
|
|
289
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
290
|
+
title: "Batch preview",
|
|
291
|
+
titleAlignment: "left",
|
|
292
|
+
});
|
|
293
|
+
console.log(`\n${previewBox}\n`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Post-run cleanup ──────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
const MEDIA_EXT_RE =
|
|
299
|
+
/\S+\.(?:png|jpg|jpeg|gif|webp|avif|mp4|mov|mkv|mp3|wav|flac|aac)/gi;
|
|
300
|
+
|
|
301
|
+
function inferInputFiles(commands: string[]): string[] {
|
|
302
|
+
const files = new Set<string>();
|
|
303
|
+
for (const cmd of commands) {
|
|
304
|
+
const matches = cmd.match(MEDIA_EXT_RE);
|
|
305
|
+
if (matches) {
|
|
306
|
+
for (const m of matches) files.add(m);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return [...files];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function runCleanup(files: string[]): Promise<void> {
|
|
313
|
+
if (files.length === 0) return;
|
|
314
|
+
|
|
315
|
+
p.log.info(`Input files detected:\n ${files.join("\n ")}`);
|
|
316
|
+
|
|
317
|
+
const confirm = await p.confirm({
|
|
318
|
+
message: "Delete original files?",
|
|
319
|
+
initialValue: false,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (p.isCancel(confirm) || !confirm) return;
|
|
323
|
+
|
|
324
|
+
for (const file of files) {
|
|
325
|
+
try {
|
|
326
|
+
await Bun.$`rm ${file}`;
|
|
327
|
+
p.log.success(`Deleted ${file}`);
|
|
328
|
+
} catch {
|
|
329
|
+
p.log.error(`Failed to delete ${file}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Conversion helpers ────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
async function tryGenerate(
|
|
337
|
+
userRequest: string,
|
|
338
|
+
ctx: ToolCtx,
|
|
339
|
+
config: AviconConfig,
|
|
340
|
+
): Promise<AiResult | null> {
|
|
341
|
+
const s = p.spinner();
|
|
342
|
+
s.start("Generating command");
|
|
343
|
+
try {
|
|
344
|
+
const result = await generate(
|
|
345
|
+
buildSystemPrompt(ctx),
|
|
346
|
+
buildUserPrompt(userRequest),
|
|
347
|
+
config,
|
|
348
|
+
);
|
|
349
|
+
s.stop("Done.");
|
|
350
|
+
return result;
|
|
351
|
+
} catch (err) {
|
|
352
|
+
s.stop("Failed.");
|
|
353
|
+
if (err instanceof ValidationError) {
|
|
354
|
+
p.log.error("Could not parse AI response:");
|
|
355
|
+
console.log(err.raw);
|
|
356
|
+
} else {
|
|
357
|
+
p.log.error(err instanceof Error ? err.message : String(err));
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Returns updated request string (same or edited) on retry, null on cancel.
|
|
364
|
+
async function promptErrorRecovery(
|
|
365
|
+
currentRequest: string,
|
|
366
|
+
): Promise<string | null> {
|
|
367
|
+
const recovery = await p.select({
|
|
368
|
+
message: "What would you like to do?",
|
|
369
|
+
options: [
|
|
370
|
+
{ value: "retry", label: "Retry", hint: "regenerate with same prompt" },
|
|
371
|
+
{
|
|
372
|
+
value: "edit-prompt",
|
|
373
|
+
label: "Edit prompt",
|
|
374
|
+
hint: "modify your request and retry",
|
|
375
|
+
},
|
|
376
|
+
{ value: "cancel", label: "Cancel" },
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
if (p.isCancel(recovery) || recovery === "cancel") return null;
|
|
380
|
+
if (recovery !== "edit-prompt") return currentRequest;
|
|
381
|
+
|
|
382
|
+
const edited = await p.text({
|
|
383
|
+
message: "Edit your request:",
|
|
384
|
+
initialValue: currentRequest,
|
|
385
|
+
});
|
|
386
|
+
if (p.isCancel(edited)) return null;
|
|
387
|
+
return edited as string;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Retries until a successful AiResult is obtained or the user cancels.
|
|
391
|
+
async function generateUntilSuccess(
|
|
392
|
+
initialRequest: string,
|
|
393
|
+
ctx: ToolCtx,
|
|
394
|
+
config: AviconConfig,
|
|
395
|
+
): Promise<{ result: AiResult; userRequest: string }> {
|
|
396
|
+
let userRequest = initialRequest;
|
|
397
|
+
for (;;) {
|
|
398
|
+
const result = await tryGenerate(userRequest, ctx, config);
|
|
399
|
+
if (result !== null) return { result, userRequest };
|
|
400
|
+
const next = await promptErrorRecovery(userRequest);
|
|
401
|
+
if (next === null) {
|
|
402
|
+
p.outro("Cancelled.");
|
|
403
|
+
process.exit(0);
|
|
404
|
+
}
|
|
405
|
+
userRequest = next;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function handleEditPromptAction(
|
|
410
|
+
userRequest: string,
|
|
411
|
+
ctx: ToolCtx,
|
|
412
|
+
config: AviconConfig,
|
|
413
|
+
): Promise<{ result: AiResult; userRequest: string }> {
|
|
414
|
+
const edited = await p.text({
|
|
415
|
+
message: "Edit your request:",
|
|
416
|
+
initialValue: userRequest,
|
|
417
|
+
});
|
|
418
|
+
if (p.isCancel(edited)) {
|
|
419
|
+
p.outro("Cancelled.");
|
|
420
|
+
process.exit(0);
|
|
421
|
+
}
|
|
422
|
+
return generateUntilSuccess(edited as string, ctx, config);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function handleCopyAction(commands: string[]): Promise<void> {
|
|
426
|
+
const ok = await copyToClipboard(commands.join("\n"));
|
|
427
|
+
if (ok) {
|
|
428
|
+
p.log.success("Commands copied to clipboard.");
|
|
429
|
+
} else {
|
|
430
|
+
p.log.warn("No clipboard tool found. Install xclip, xsel, or wl-copy.");
|
|
431
|
+
}
|
|
432
|
+
process.exit(0);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function handleEditCommandsAction(
|
|
436
|
+
current: GenerateResult,
|
|
437
|
+
): Promise<GenerateResult> {
|
|
438
|
+
const edited = await p.text({
|
|
439
|
+
message: "Edit commands (one per line):",
|
|
440
|
+
initialValue: current.commands.join("\n"),
|
|
441
|
+
});
|
|
442
|
+
if (p.isCancel(edited)) {
|
|
443
|
+
p.outro("Cancelled.");
|
|
444
|
+
process.exit(0);
|
|
445
|
+
}
|
|
446
|
+
const newCommands = (edited as string)
|
|
447
|
+
.split("\n")
|
|
448
|
+
.map((l) => l.trim())
|
|
449
|
+
.filter(Boolean);
|
|
450
|
+
return { ...current, commands: newCommands };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function handleRunAction(commands: string[]): Promise<void> {
|
|
454
|
+
const preExisting = new Set(
|
|
455
|
+
await Promise.all(
|
|
456
|
+
inferInputFiles(commands).map(async (f) => {
|
|
457
|
+
const file = Bun.file(f);
|
|
458
|
+
return (await file.exists()) ? f : null;
|
|
459
|
+
}),
|
|
460
|
+
).then((results) => results.filter((f): f is string => f !== null)),
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const success = await runCommands(commands, {
|
|
464
|
+
onBefore: (cmd, i, total) => p.log.step(`▶ [${i + 1}/${total}] ${cmd}`),
|
|
465
|
+
onSuccess: () => p.log.success("All commands completed successfully."),
|
|
466
|
+
onError: (cmd, exitCode) =>
|
|
467
|
+
p.log.error(`Command exited with code ${exitCode}: ${cmd}`),
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
if (success) {
|
|
471
|
+
await runCleanup([...preExisting]);
|
|
472
|
+
}
|
|
473
|
+
process.exit(success ? 0 : 1);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── Single-file flow ──────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
async function runSingleFileFlow(
|
|
479
|
+
initial: GenerateResult,
|
|
480
|
+
initialRequest: string,
|
|
481
|
+
ctx: ToolCtx,
|
|
482
|
+
config: AviconConfig,
|
|
483
|
+
): Promise<void> {
|
|
484
|
+
let currentResult = initial;
|
|
485
|
+
let userRequest = initialRequest;
|
|
486
|
+
|
|
487
|
+
renderPanels(currentResult);
|
|
488
|
+
|
|
489
|
+
while (true) {
|
|
490
|
+
const hasCommands = currentResult.commands.length > 0;
|
|
491
|
+
const options = [
|
|
492
|
+
...(hasCommands
|
|
493
|
+
? [
|
|
494
|
+
{ value: "run", label: "Run all" },
|
|
495
|
+
{
|
|
496
|
+
value: "edit",
|
|
497
|
+
label: "Edit commands",
|
|
498
|
+
hint: "tweak the generated commands",
|
|
499
|
+
},
|
|
500
|
+
]
|
|
501
|
+
: []),
|
|
502
|
+
{ value: "retry", label: "Retry", hint: "regenerate with same prompt" },
|
|
503
|
+
{
|
|
504
|
+
value: "edit-prompt",
|
|
505
|
+
label: "Edit prompt",
|
|
506
|
+
hint: "modify request and retry",
|
|
507
|
+
},
|
|
508
|
+
...(hasCommands ? [{ value: "copy", label: "Copy" }] : []),
|
|
509
|
+
{ value: "cancel", label: "Cancel" },
|
|
510
|
+
];
|
|
511
|
+
const action = await p.select({
|
|
512
|
+
message: "What would you like to do?",
|
|
513
|
+
options,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (p.isCancel(action) || action === "cancel") {
|
|
517
|
+
p.outro("Cancelled.");
|
|
518
|
+
process.exit(0);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (action === "retry") {
|
|
522
|
+
const gen = await generateUntilSuccess(userRequest, ctx, config);
|
|
523
|
+
userRequest = gen.userRequest;
|
|
524
|
+
if ("multi_file" in gen.result) {
|
|
525
|
+
await handleBatchFlow(
|
|
526
|
+
gen.result as MultiFileResult,
|
|
527
|
+
userRequest,
|
|
528
|
+
ctx,
|
|
529
|
+
config,
|
|
530
|
+
);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
currentResult = gen.result as GenerateResult;
|
|
534
|
+
renderPanels(currentResult);
|
|
535
|
+
} else if (action === "edit-prompt") {
|
|
536
|
+
const gen = await handleEditPromptAction(userRequest, ctx, config);
|
|
537
|
+
userRequest = gen.userRequest;
|
|
538
|
+
if ("multi_file" in gen.result) {
|
|
539
|
+
await handleBatchFlow(
|
|
540
|
+
gen.result as MultiFileResult,
|
|
541
|
+
userRequest,
|
|
542
|
+
ctx,
|
|
543
|
+
config,
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
currentResult = gen.result as GenerateResult;
|
|
548
|
+
renderPanels(currentResult);
|
|
549
|
+
} else if (action === "copy") {
|
|
550
|
+
await handleCopyAction(currentResult.commands);
|
|
551
|
+
} else if (action === "edit") {
|
|
552
|
+
currentResult = await handleEditCommandsAction(currentResult);
|
|
553
|
+
renderPanels(currentResult);
|
|
554
|
+
} else if (action === "run") {
|
|
555
|
+
await handleRunAction(currentResult.commands);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── Batch flow ────────────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
async function handleBatchFlow(
|
|
563
|
+
initial: MultiFileResult,
|
|
564
|
+
initialRequest: string,
|
|
565
|
+
ctx: ToolCtx,
|
|
566
|
+
config: AviconConfig,
|
|
567
|
+
): Promise<void> {
|
|
568
|
+
let result = initial;
|
|
569
|
+
let userRequest = initialRequest;
|
|
570
|
+
|
|
571
|
+
for (;;) {
|
|
572
|
+
// 1. Expand globs
|
|
573
|
+
const files = await expandGlob(result.glob);
|
|
574
|
+
|
|
575
|
+
if (files.length === 0) {
|
|
576
|
+
p.log.warn(`No files matched: ${result.glob.join(", ")}`);
|
|
577
|
+
const recovery = await p.select({
|
|
578
|
+
message: "What would you like to do?",
|
|
579
|
+
options: [
|
|
580
|
+
{
|
|
581
|
+
value: "retry",
|
|
582
|
+
label: "Retry",
|
|
583
|
+
hint: "regenerate with same prompt",
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
value: "edit-prompt",
|
|
587
|
+
label: "Edit prompt",
|
|
588
|
+
hint: "modify request and retry",
|
|
589
|
+
},
|
|
590
|
+
{ value: "cancel", label: "Cancel" },
|
|
591
|
+
],
|
|
592
|
+
});
|
|
593
|
+
if (p.isCancel(recovery) || recovery === "cancel") {
|
|
594
|
+
p.outro("Cancelled.");
|
|
595
|
+
process.exit(0);
|
|
596
|
+
}
|
|
597
|
+
const gen =
|
|
598
|
+
recovery === "retry"
|
|
599
|
+
? await generateUntilSuccess(userRequest, ctx, config)
|
|
600
|
+
: await handleEditPromptAction(userRequest, ctx, config);
|
|
601
|
+
userRequest = gen.userRequest;
|
|
602
|
+
if (!("multi_file" in gen.result)) {
|
|
603
|
+
await runSingleFileFlow(
|
|
604
|
+
gen.result as GenerateResult,
|
|
605
|
+
userRequest,
|
|
606
|
+
ctx,
|
|
607
|
+
config,
|
|
608
|
+
);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
result = gen.result as MultiFileResult;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 2. Build file groups and render
|
|
616
|
+
const fileGroups = buildBatchCommands(
|
|
617
|
+
files,
|
|
618
|
+
result.commands,
|
|
619
|
+
result.output_template,
|
|
620
|
+
);
|
|
621
|
+
renderBatchPanels(result, fileGroups);
|
|
622
|
+
|
|
623
|
+
// 3. Action loop
|
|
624
|
+
const totalSteps = fileGroups.reduce((n, g) => n + g.commands.length, 0);
|
|
625
|
+
const action = await p.select({
|
|
626
|
+
message: "What would you like to do?",
|
|
627
|
+
options: [
|
|
628
|
+
{
|
|
629
|
+
value: "run",
|
|
630
|
+
label: `Run all (${files.length} file${files.length !== 1 ? "s" : ""}, ${result.commands.length} step${result.commands.length !== 1 ? "s" : ""} each)`,
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
value: "edit-template",
|
|
634
|
+
label: "Edit template",
|
|
635
|
+
hint: "modify the command templates",
|
|
636
|
+
},
|
|
637
|
+
{ value: "retry", label: "Retry", hint: "regenerate with same prompt" },
|
|
638
|
+
{
|
|
639
|
+
value: "edit-prompt",
|
|
640
|
+
label: "Edit prompt",
|
|
641
|
+
hint: "modify request and retry",
|
|
642
|
+
},
|
|
643
|
+
{ value: "cancel", label: "Cancel" },
|
|
644
|
+
],
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
if (p.isCancel(action) || action === "cancel") {
|
|
648
|
+
p.outro("Cancelled.");
|
|
649
|
+
process.exit(0);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (action === "run") {
|
|
653
|
+
const allCommands = fileGroups.flatMap((g) => g.commands);
|
|
654
|
+
const success = await runCommands(allCommands, {
|
|
655
|
+
onBefore: (cmd, i) => p.log.step(`▶ [${i + 1}/${totalSteps}] ${cmd}`),
|
|
656
|
+
onSuccess: () => p.log.success("All commands completed successfully."),
|
|
657
|
+
onError: (cmd, exitCode) =>
|
|
658
|
+
p.log.error(`Command exited with code ${exitCode}: ${cmd}`),
|
|
659
|
+
});
|
|
660
|
+
if (success) {
|
|
661
|
+
await runCleanup(files);
|
|
662
|
+
}
|
|
663
|
+
process.exit(success ? 0 : 1);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (action === "edit-template") {
|
|
667
|
+
const edited = await p.text({
|
|
668
|
+
message: "Edit command templates (one per line):",
|
|
669
|
+
initialValue: result.commands.join("\n"),
|
|
670
|
+
});
|
|
671
|
+
if (p.isCancel(edited)) {
|
|
672
|
+
p.outro("Cancelled.");
|
|
673
|
+
process.exit(0);
|
|
674
|
+
}
|
|
675
|
+
const newCommands = (edited as string)
|
|
676
|
+
.split("\n")
|
|
677
|
+
.map((l) => l.trim())
|
|
678
|
+
.filter(Boolean);
|
|
679
|
+
result = { ...result, commands: newCommands };
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (action === "retry" || action === "edit-prompt") {
|
|
684
|
+
const gen =
|
|
685
|
+
action === "retry"
|
|
686
|
+
? await generateUntilSuccess(userRequest, ctx, config)
|
|
687
|
+
: await handleEditPromptAction(userRequest, ctx, config);
|
|
688
|
+
userRequest = gen.userRequest;
|
|
689
|
+
if (!("multi_file" in gen.result)) {
|
|
690
|
+
await runSingleFileFlow(
|
|
691
|
+
gen.result as GenerateResult,
|
|
692
|
+
userRequest,
|
|
693
|
+
ctx,
|
|
694
|
+
config,
|
|
695
|
+
);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
result = gen.result as MultiFileResult;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Conversion flow ───────────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
async function runConversion(
|
|
707
|
+
initialRequest: string,
|
|
708
|
+
config: AviconConfig,
|
|
709
|
+
): Promise<void> {
|
|
710
|
+
const toolSpinner = p.spinner();
|
|
711
|
+
toolSpinner.start("Detecting tools");
|
|
712
|
+
const ctx = await detectContext();
|
|
713
|
+
toolSpinner.stop("Tools detected.");
|
|
714
|
+
p.log.info(renderToolSummary(ctx));
|
|
715
|
+
|
|
716
|
+
if (!ctx.ffmpeg.installed && !ctx.magick.installed) {
|
|
717
|
+
p.log.error(
|
|
718
|
+
"No media tools found. Install FFmpeg or ImageMagick and try again.",
|
|
719
|
+
);
|
|
720
|
+
process.exit(1);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const { result, userRequest } = await generateUntilSuccess(
|
|
724
|
+
initialRequest,
|
|
725
|
+
ctx,
|
|
726
|
+
config,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
if ("multi_file" in result) {
|
|
730
|
+
await handleBatchFlow(result as MultiFileResult, userRequest, ctx, config);
|
|
731
|
+
} else {
|
|
732
|
+
await runSingleFileFlow(result as GenerateResult, userRequest, ctx, config);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
737
|
+
|
|
738
|
+
const subcommand = args[0];
|
|
739
|
+
|
|
740
|
+
if (subcommand === "setup") {
|
|
741
|
+
await runSetup();
|
|
742
|
+
process.exit(0);
|
|
743
|
+
} else if (subcommand === "teardown") {
|
|
744
|
+
await runTeardown();
|
|
745
|
+
process.exit(0);
|
|
746
|
+
} else {
|
|
747
|
+
// First non-flag positional arg is the conversion request
|
|
748
|
+
const request = args.find((a) => !a.startsWith("-"));
|
|
749
|
+
|
|
750
|
+
// Config is loaded here so --provider override can be applied
|
|
751
|
+
let config = await getConfig();
|
|
752
|
+
|
|
753
|
+
if (providerOverride) {
|
|
754
|
+
if (config) {
|
|
755
|
+
config = { ...config, defaultProvider: providerOverride };
|
|
756
|
+
} else {
|
|
757
|
+
config = { defaultProvider: providerOverride };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (!config) {
|
|
762
|
+
showBanner();
|
|
763
|
+
p.log.error("No provider configured. Run: avicon setup");
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (config.defaultProvider === "cloudflare" && !config.cloudflare) {
|
|
768
|
+
showBanner();
|
|
769
|
+
p.log.error("Cloudflare credentials missing. Run: avicon setup");
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
showBanner();
|
|
774
|
+
|
|
775
|
+
if (!request) {
|
|
776
|
+
p.log.info("Usage: avicon <request> | avicon --help for more");
|
|
777
|
+
process.exit(0);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
await runConversion(request, config);
|
|
781
|
+
}
|