@seanmozeik/vicon 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/README.md +207 -0
- package/package.json +49 -0
- package/src/index.ts +523 -0
- package/src/lib/ai.ts +112 -0
- package/src/lib/clipboard.ts +40 -0
- package/src/lib/config.ts +83 -0
- package/src/lib/prompt.ts +28 -0
- package/src/lib/run.ts +35 -0
- package/src/lib/secrets.ts +2 -0
- package/src/lib/tools.ts +67 -0
- package/src/types.ts +18 -0
- package/src/ui/banner.ts +25 -0
- package/src/ui/theme.ts +162 -0
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# vicon
|
|
2
|
+
|
|
3
|
+
Describe what you want. Get the shell commands.
|
|
4
|
+
|
|
5
|
+
Vicon is a CLI that turns plain-English media requests into executable ffmpeg and ImageMagick commands. It detects your installed tools and their capabilities, sends your request to an AI provider, and presents the generated commands for review before running them.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
vicon "convert video.mp4 to gif at 15fps"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
╭──────────── What this does ────────────╮
|
|
13
|
+
│ │
|
|
14
|
+
│ Converts your MP4 to an animated GIF │
|
|
15
|
+
│ at 15 frames per second. │
|
|
16
|
+
│ │
|
|
17
|
+
╰────────────────────────────────────────╯
|
|
18
|
+
|
|
19
|
+
╭ Commands ──────────────────────────────╮
|
|
20
|
+
│ [1] ffmpeg -i video.mp4 -vf "fps=15, │
|
|
21
|
+
│ scale=480:-1" video_converted.gif │
|
|
22
|
+
╰────────────────────────────────────────╯
|
|
23
|
+
|
|
24
|
+
◆ What would you like to do?
|
|
25
|
+
│ ● Run all
|
|
26
|
+
│ ○ Edit commands
|
|
27
|
+
│ ○ Retry
|
|
28
|
+
│ ○ Edit prompt
|
|
29
|
+
│ ○ Copy
|
|
30
|
+
│ ○ Cancel
|
|
31
|
+
└
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Requires [Bun](https://bun.sh).
|
|
37
|
+
|
|
38
|
+
**Homebrew:**
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
brew install seanmozeik/tap/vicon
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**npm:**
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bun add -g @seanmozeik/vicon
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**From source:**
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone https://github.com/seanmozeik/vicon.git
|
|
54
|
+
cd vicon
|
|
55
|
+
bun install
|
|
56
|
+
bun run build # compiled binary → ./vicon
|
|
57
|
+
bun run install-local # moves binary to ~/.local/bin
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Setup
|
|
61
|
+
|
|
62
|
+
Run `vicon setup` to configure your AI provider.
|
|
63
|
+
|
|
64
|
+
**Cloudflare AI** requires an account ID and API token. Credentials are stored in your system keychain (macOS Keychain, Linux libsecret).
|
|
65
|
+
|
|
66
|
+
**Claude Code CLI** requires the `claude` binary on your PATH. No additional credentials needed.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
vicon setup
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
◆ Which AI provider?
|
|
74
|
+
│ ● Cloudflare AI (requires Account ID + API token)
|
|
75
|
+
│ ○ Claude Code CLI (requires claude CLI installed)
|
|
76
|
+
└
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For headless or CI environments, set the `VICON_CONFIG` environment variable:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
export VICON_CONFIG='{"defaultProvider":"cloudflare","cloudflare":{"accountId":"...","apiToken":"..."}}'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Linux users need libsecret for keychain storage:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Ubuntu/Debian
|
|
89
|
+
sudo apt install libsecret-1-0 libsecret-tools
|
|
90
|
+
|
|
91
|
+
# Fedora
|
|
92
|
+
sudo dnf install libsecret
|
|
93
|
+
|
|
94
|
+
# Arch
|
|
95
|
+
sudo pacman -S libsecret
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Usage
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
vicon "<your request>"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Vicon detects ffmpeg and ImageMagick on startup, inventories their encoders, decoders, and supported formats, and feeds this context to the AI. The generated commands match your actual environment.
|
|
105
|
+
|
|
106
|
+
### Examples
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Video
|
|
110
|
+
vicon "convert video.mp4 to gif at 15fps"
|
|
111
|
+
vicon "extract audio from interview.mov as flac"
|
|
112
|
+
vicon "compress this 4K video to 1080p h265 with good quality"
|
|
113
|
+
|
|
114
|
+
# Images
|
|
115
|
+
vicon "resize all jpgs in this folder to 800px wide"
|
|
116
|
+
vicon "convert logo.png to webp and avif"
|
|
117
|
+
vicon "strip EXIF data from every image in ./photos"
|
|
118
|
+
|
|
119
|
+
# Audio
|
|
120
|
+
vicon "split podcast.mp3 into 30-minute chunks"
|
|
121
|
+
vicon "normalize volume across all wav files here"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Actions
|
|
125
|
+
|
|
126
|
+
After generation, you choose what happens next:
|
|
127
|
+
|
|
128
|
+
| Action | Effect |
|
|
129
|
+
|---|---|
|
|
130
|
+
| **Run all** | Executes each command in sequence with live terminal output |
|
|
131
|
+
| **Edit commands** | Opens the commands in a text editor for manual tweaks |
|
|
132
|
+
| **Retry** | Regenerates with the same prompt |
|
|
133
|
+
| **Edit prompt** | Modifies your request and regenerates |
|
|
134
|
+
| **Copy** | Copies all commands to clipboard |
|
|
135
|
+
| **Cancel** | Exits |
|
|
136
|
+
|
|
137
|
+
### Provider override
|
|
138
|
+
|
|
139
|
+
Force a specific provider for one invocation:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
vicon "resize photo.jpg to 50%" --provider claude
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Post-run cleanup
|
|
146
|
+
|
|
147
|
+
After commands finish, vicon scans for media files referenced in the commands and offers to delete the originals. The default is No.
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
ℹ Input files detected:
|
|
151
|
+
video.mp4
|
|
152
|
+
◆ Delete original files?
|
|
153
|
+
│ ○ Yes / ● No
|
|
154
|
+
└
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Error recovery
|
|
158
|
+
|
|
159
|
+
If the AI returns an unparseable response, vicon shows the raw output and offers three paths: retry with the same prompt, edit your prompt and retry, or cancel. This loop continues until you get a valid result or walk away.
|
|
160
|
+
|
|
161
|
+
## Flags
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
--provider cloudflare|claude Override the default provider
|
|
165
|
+
--help, -h Show usage and examples
|
|
166
|
+
--version, -v Print version
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Subcommands
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
setup Configure AI provider credentials
|
|
173
|
+
teardown Remove saved credentials from keychain
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Tool detection
|
|
177
|
+
|
|
178
|
+
On each run, vicon probes for:
|
|
179
|
+
|
|
180
|
+
- **ffmpeg**: version, full encoder list, full decoder list
|
|
181
|
+
- **ImageMagick**: version, supported format list
|
|
182
|
+
|
|
183
|
+
This context goes into the system prompt so the AI only generates commands your system can execute. If a tool is missing, vicon warns you and the AI works around it.
|
|
184
|
+
|
|
185
|
+
## How it works
|
|
186
|
+
|
|
187
|
+
1. Parse your request and detect installed tools
|
|
188
|
+
2. Build a system prompt containing tool versions, codecs, and formats
|
|
189
|
+
3. Send the request to Cloudflare AI or Claude Code CLI
|
|
190
|
+
4. Parse the JSON response into commands and a plain-English explanation
|
|
191
|
+
5. Display both in separate panels for review
|
|
192
|
+
6. Execute, edit, retry, copy, or cancel
|
|
193
|
+
|
|
194
|
+
Generated commands use safe defaults: output files get a `_converted` suffix, existing files are never overwritten silently.
|
|
195
|
+
|
|
196
|
+
## Development
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
bun install
|
|
200
|
+
bun --hot src/index.ts # dev mode with hot reload
|
|
201
|
+
bun test # run tests
|
|
202
|
+
bun run build # compile to standalone binary
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seanmozeik/vicon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered media conversion CLI — describe what you want, get the commands",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vicon": "src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"package.json"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"media",
|
|
15
|
+
"conversion",
|
|
16
|
+
"ffmpeg",
|
|
17
|
+
"imagemagick",
|
|
18
|
+
"cli",
|
|
19
|
+
"ai",
|
|
20
|
+
"bun"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/seanmozeik/vicon.git"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@clack/prompts": "latest",
|
|
32
|
+
"boxen": "latest",
|
|
33
|
+
"gradient-string": "latest",
|
|
34
|
+
"picocolors": "latest"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/bun": "latest"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"typescript": "^5"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "bun build ./src/index.ts --compile --outfile vicon --minify",
|
|
44
|
+
"bundle": "bun build ./src/index.ts --outdir dist --target=bun --bytecode --minify",
|
|
45
|
+
"dev": "bun --hot src/index.ts",
|
|
46
|
+
"install-local": "bun run build && mkdir -p ~/.local/bin && mv vicon ~/.local/bin/vicon && chmod +x ~/.local/bin/vicon",
|
|
47
|
+
"test": "bun test"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import boxen from "boxen";
|
|
3
|
+
import { generate, ValidationError } from "./lib/ai.js";
|
|
4
|
+
import { copyToClipboard } from "./lib/clipboard.js";
|
|
5
|
+
import type { Provider, ViconConfig } from "./lib/config.js";
|
|
6
|
+
import { deleteConfig, getConfig, setConfig } from "./lib/config.js";
|
|
7
|
+
import { buildSystemPrompt, buildUserPrompt } from "./lib/prompt.js";
|
|
8
|
+
import { runCommands } from "./lib/run.js";
|
|
9
|
+
import { detectContext } from "./lib/tools.js";
|
|
10
|
+
import type { GenerateResult } from "./types.js";
|
|
11
|
+
import { showBanner } from "./ui/banner.js";
|
|
12
|
+
import { boxColors, frappe, theme } from "./ui/theme.js";
|
|
13
|
+
|
|
14
|
+
// ── Arg parsing ──────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
|
|
18
|
+
function popFlag(flags: string[]): string | undefined {
|
|
19
|
+
for (const flag of flags) {
|
|
20
|
+
const i = args.indexOf(flag);
|
|
21
|
+
if (i !== -1) {
|
|
22
|
+
args.splice(i, 1);
|
|
23
|
+
return flag;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function popFlagValue(flag: string): string | undefined {
|
|
30
|
+
const i = args.indexOf(flag);
|
|
31
|
+
if (i !== -1 && i + 1 < args.length) {
|
|
32
|
+
const val = args[i + 1];
|
|
33
|
+
args.splice(i, 2);
|
|
34
|
+
return val;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const helpFlag = popFlag(["--help", "-h"]);
|
|
40
|
+
const versionFlag = popFlag(["--version", "-v"]);
|
|
41
|
+
const providerOverride = popFlagValue("--provider") as Provider | undefined;
|
|
42
|
+
|
|
43
|
+
// ── Help & Version ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
if (versionFlag) {
|
|
46
|
+
const pkg = (await import("../package.json")) as { version: string };
|
|
47
|
+
console.log(`vicon v${pkg.version}`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (helpFlag) {
|
|
52
|
+
showBanner();
|
|
53
|
+
console.log(
|
|
54
|
+
[
|
|
55
|
+
"",
|
|
56
|
+
` ${theme.heading("Usage:")} vicon <request> [--provider cloudflare|claude]`,
|
|
57
|
+
"",
|
|
58
|
+
` ${theme.heading("Subcommands:")}`,
|
|
59
|
+
` ${frappe.sky("setup")} Configure AI provider credentials`,
|
|
60
|
+
` ${frappe.sky("teardown")} Remove saved credentials`,
|
|
61
|
+
"",
|
|
62
|
+
` ${theme.heading("Flags:")}`,
|
|
63
|
+
` ${frappe.sky("--provider")} Override provider for this invocation`,
|
|
64
|
+
` ${frappe.sky("--help")} Show this help`,
|
|
65
|
+
` ${frappe.sky("--version")} Print version`,
|
|
66
|
+
"",
|
|
67
|
+
` ${theme.heading("Examples:")}`,
|
|
68
|
+
` vicon "convert video.mp4 to gif at 15fps"`,
|
|
69
|
+
` vicon "resize all jpgs in this folder to 800px wide"`,
|
|
70
|
+
` vicon "extract audio from interview.mov as flac" --provider claude`,
|
|
71
|
+
"",
|
|
72
|
+
].join("\n"),
|
|
73
|
+
);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Subcommands ───────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async function setupCloudflare(): Promise<void> {
|
|
80
|
+
const accountId = await p.text({
|
|
81
|
+
message: "Cloudflare Account ID:",
|
|
82
|
+
validate: (v) => (v?.trim() ? undefined : "Required"),
|
|
83
|
+
});
|
|
84
|
+
if (p.isCancel(accountId)) {
|
|
85
|
+
p.cancel("Setup cancelled.");
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const apiToken = await p.password({
|
|
90
|
+
message: "Cloudflare AI API token:",
|
|
91
|
+
validate: (v) => (v?.trim() ? undefined : "Required"),
|
|
92
|
+
});
|
|
93
|
+
if (p.isCancel(apiToken)) {
|
|
94
|
+
p.cancel("Setup cancelled.");
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const config: ViconConfig = {
|
|
99
|
+
defaultProvider: "cloudflare",
|
|
100
|
+
cloudflare: {
|
|
101
|
+
accountId: (accountId as string).trim(),
|
|
102
|
+
apiToken: (apiToken as string).trim(),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
await setConfig(config);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
p.log.error(err instanceof Error ? err.message : String(err));
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
p.outro("Cloudflare AI configured and saved.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function runSetup(): Promise<void> {
|
|
115
|
+
showBanner();
|
|
116
|
+
p.intro("Configure vicon AI provider");
|
|
117
|
+
|
|
118
|
+
const provider = await p.select<Provider>({
|
|
119
|
+
message: "Which AI provider?",
|
|
120
|
+
options: [
|
|
121
|
+
{
|
|
122
|
+
value: "cloudflare" as Provider,
|
|
123
|
+
label: "Cloudflare AI",
|
|
124
|
+
hint: "requires Account ID + API token",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
value: "claude" as Provider,
|
|
128
|
+
label: "Claude Code CLI",
|
|
129
|
+
hint: "requires claude CLI installed",
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (p.isCancel(provider)) {
|
|
135
|
+
p.cancel("Setup cancelled.");
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if ((provider as Provider) === "cloudflare") {
|
|
140
|
+
await setupCloudflare();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// claude — verify CLI is available
|
|
145
|
+
const proc = Bun.spawn(["which", "claude"], {
|
|
146
|
+
stdout: "pipe",
|
|
147
|
+
stderr: "pipe",
|
|
148
|
+
});
|
|
149
|
+
await proc.exited;
|
|
150
|
+
if (proc.exitCode !== 0) {
|
|
151
|
+
p.log.error(
|
|
152
|
+
"claude CLI not found. Install it from https://claude.ai/code and re-run setup.",
|
|
153
|
+
);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const config: ViconConfig = { defaultProvider: "claude" };
|
|
158
|
+
try {
|
|
159
|
+
await setConfig(config);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
p.log.error(err instanceof Error ? err.message : String(err));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
p.outro("Claude Code CLI configured and saved.");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function runTeardown(): Promise<void> {
|
|
168
|
+
showBanner();
|
|
169
|
+
|
|
170
|
+
const confirm = await p.confirm({
|
|
171
|
+
message: "Delete vicon config from keychain?",
|
|
172
|
+
initialValue: false,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
176
|
+
p.outro("Teardown cancelled.");
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
await deleteConfig();
|
|
181
|
+
p.outro("Config deleted.");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Tool summary line ─────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
type ToolCtx = Awaited<ReturnType<typeof detectContext>>;
|
|
187
|
+
|
|
188
|
+
function renderToolSummary(ctx: ToolCtx): string {
|
|
189
|
+
const parts: string[] = [];
|
|
190
|
+
|
|
191
|
+
if (ctx.ffmpeg.installed) {
|
|
192
|
+
const ver = ctx.ffmpeg.version ?? "?";
|
|
193
|
+
const enc = ctx.ffmpeg.encoders.length;
|
|
194
|
+
const dec = ctx.ffmpeg.decoders.length;
|
|
195
|
+
parts.push(
|
|
196
|
+
theme.muted(`ffmpeg ${ver} (${enc} encoders · ${dec} decoders)`),
|
|
197
|
+
);
|
|
198
|
+
} else {
|
|
199
|
+
parts.push(frappe.yellow("ffmpeg not found"));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (ctx.magick.installed) {
|
|
203
|
+
const ver = ctx.magick.version ?? "?";
|
|
204
|
+
const fmt = ctx.magick.formats.length;
|
|
205
|
+
parts.push(theme.muted(`magick ${ver} (${fmt} formats)`));
|
|
206
|
+
} else {
|
|
207
|
+
parts.push(frappe.yellow("magick not found"));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return parts.join(theme.muted(" · "));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Display panels ────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function renderPanels(result: GenerateResult): void {
|
|
216
|
+
const explanationBox = boxen(result.explanation, {
|
|
217
|
+
borderColor: boxColors.primary,
|
|
218
|
+
borderStyle: "round",
|
|
219
|
+
padding: { top: 1, bottom: 1, left: 2, right: 2 },
|
|
220
|
+
title: "What this does",
|
|
221
|
+
titleAlignment: "center",
|
|
222
|
+
});
|
|
223
|
+
console.log(`\n${explanationBox}`);
|
|
224
|
+
|
|
225
|
+
const numberedCmds = result.commands
|
|
226
|
+
.map((cmd, i) => `${frappe.sky(`[${i + 1}]`)} ${cmd}`)
|
|
227
|
+
.join("\n");
|
|
228
|
+
|
|
229
|
+
const commandsBox = boxen(numberedCmds, {
|
|
230
|
+
borderColor: boxColors.default,
|
|
231
|
+
dimBorder: true,
|
|
232
|
+
borderStyle: "round",
|
|
233
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
234
|
+
title: "Commands",
|
|
235
|
+
titleAlignment: "left",
|
|
236
|
+
});
|
|
237
|
+
console.log(`\n${commandsBox}\n`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Post-run cleanup ──────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
const MEDIA_EXT_RE =
|
|
243
|
+
/\S+\.(?:png|jpg|jpeg|gif|webp|avif|mp4|mov|mkv|mp3|wav|flac|aac)/gi;
|
|
244
|
+
|
|
245
|
+
function inferInputFiles(commands: string[]): string[] {
|
|
246
|
+
const files = new Set<string>();
|
|
247
|
+
for (const cmd of commands) {
|
|
248
|
+
const matches = cmd.match(MEDIA_EXT_RE);
|
|
249
|
+
if (matches) {
|
|
250
|
+
for (const m of matches) files.add(m);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return [...files];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function runCleanup(files: string[]): Promise<void> {
|
|
257
|
+
if (files.length === 0) return;
|
|
258
|
+
|
|
259
|
+
p.log.info(`Input files detected:\n ${files.join("\n ")}`);
|
|
260
|
+
|
|
261
|
+
const confirm = await p.confirm({
|
|
262
|
+
message: "Delete original files?",
|
|
263
|
+
initialValue: false,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (p.isCancel(confirm) || !confirm) return;
|
|
267
|
+
|
|
268
|
+
for (const file of files) {
|
|
269
|
+
try {
|
|
270
|
+
await Bun.$`rm ${file}`;
|
|
271
|
+
p.log.success(`Deleted ${file}`);
|
|
272
|
+
} catch {
|
|
273
|
+
p.log.error(`Failed to delete ${file}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Conversion helpers ────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
async function tryGenerate(
|
|
281
|
+
userRequest: string,
|
|
282
|
+
ctx: ToolCtx,
|
|
283
|
+
config: ViconConfig,
|
|
284
|
+
): Promise<GenerateResult | null> {
|
|
285
|
+
const s = p.spinner();
|
|
286
|
+
s.start("Generating command…");
|
|
287
|
+
try {
|
|
288
|
+
const result = await generate(
|
|
289
|
+
buildSystemPrompt(ctx),
|
|
290
|
+
buildUserPrompt(userRequest),
|
|
291
|
+
config,
|
|
292
|
+
);
|
|
293
|
+
s.stop("Done.");
|
|
294
|
+
return result;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
s.stop("Failed.");
|
|
297
|
+
if (err instanceof ValidationError) {
|
|
298
|
+
p.log.error("Could not parse AI response:");
|
|
299
|
+
console.log(err.raw);
|
|
300
|
+
} else {
|
|
301
|
+
p.log.error(err instanceof Error ? err.message : String(err));
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Returns updated request string (same or edited) on retry, null on cancel.
|
|
308
|
+
async function promptErrorRecovery(
|
|
309
|
+
currentRequest: string,
|
|
310
|
+
): Promise<string | null> {
|
|
311
|
+
const recovery = await p.select({
|
|
312
|
+
message: "What would you like to do?",
|
|
313
|
+
options: [
|
|
314
|
+
{ value: "retry", label: "Retry", hint: "regenerate with same prompt" },
|
|
315
|
+
{
|
|
316
|
+
value: "edit-prompt",
|
|
317
|
+
label: "Edit prompt",
|
|
318
|
+
hint: "modify your request and retry",
|
|
319
|
+
},
|
|
320
|
+
{ value: "cancel", label: "Cancel" },
|
|
321
|
+
],
|
|
322
|
+
});
|
|
323
|
+
if (p.isCancel(recovery) || recovery === "cancel") return null;
|
|
324
|
+
if (recovery !== "edit-prompt") return currentRequest;
|
|
325
|
+
|
|
326
|
+
const edited = await p.text({
|
|
327
|
+
message: "Edit your request:",
|
|
328
|
+
initialValue: currentRequest,
|
|
329
|
+
});
|
|
330
|
+
if (p.isCancel(edited)) return null;
|
|
331
|
+
return edited as string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Retries until a successful GenerateResult is obtained or the user cancels.
|
|
335
|
+
async function generateUntilSuccess(
|
|
336
|
+
initialRequest: string,
|
|
337
|
+
ctx: ToolCtx,
|
|
338
|
+
config: ViconConfig,
|
|
339
|
+
): Promise<{ result: GenerateResult; userRequest: string }> {
|
|
340
|
+
let userRequest = initialRequest;
|
|
341
|
+
for (;;) {
|
|
342
|
+
const result = await tryGenerate(userRequest, ctx, config);
|
|
343
|
+
if (result !== null) return { result, userRequest };
|
|
344
|
+
const next = await promptErrorRecovery(userRequest);
|
|
345
|
+
if (next === null) {
|
|
346
|
+
p.outro("Cancelled.");
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
349
|
+
userRequest = next;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function handleEditPromptAction(
|
|
354
|
+
userRequest: string,
|
|
355
|
+
ctx: ToolCtx,
|
|
356
|
+
config: ViconConfig,
|
|
357
|
+
): Promise<{ result: GenerateResult; userRequest: string }> {
|
|
358
|
+
const edited = await p.text({
|
|
359
|
+
message: "Edit your request:",
|
|
360
|
+
initialValue: userRequest,
|
|
361
|
+
});
|
|
362
|
+
if (p.isCancel(edited)) {
|
|
363
|
+
p.outro("Cancelled.");
|
|
364
|
+
process.exit(0);
|
|
365
|
+
}
|
|
366
|
+
return generateUntilSuccess(edited as string, ctx, config);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function handleCopyAction(commands: string[]): Promise<void> {
|
|
370
|
+
const ok = await copyToClipboard(commands.join("\n"));
|
|
371
|
+
if (ok) {
|
|
372
|
+
p.log.success("Commands copied to clipboard.");
|
|
373
|
+
} else {
|
|
374
|
+
p.log.warn("No clipboard tool found. Install xclip, xsel, or wl-copy.");
|
|
375
|
+
}
|
|
376
|
+
process.exit(0);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function handleEditCommandsAction(
|
|
380
|
+
current: GenerateResult,
|
|
381
|
+
): Promise<GenerateResult> {
|
|
382
|
+
const edited = await p.text({
|
|
383
|
+
message: "Edit commands (one per line):",
|
|
384
|
+
initialValue: current.commands.join("\n"),
|
|
385
|
+
});
|
|
386
|
+
if (p.isCancel(edited)) {
|
|
387
|
+
p.outro("Cancelled.");
|
|
388
|
+
process.exit(0);
|
|
389
|
+
}
|
|
390
|
+
const newCommands = (edited as string)
|
|
391
|
+
.split("\n")
|
|
392
|
+
.map((l) => l.trim())
|
|
393
|
+
.filter(Boolean);
|
|
394
|
+
return { ...current, commands: newCommands };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function handleRunAction(commands: string[]): Promise<void> {
|
|
398
|
+
const success = await runCommands(commands, {
|
|
399
|
+
onBefore: (cmd, i, total) => p.log.step(`▶ [${i + 1}/${total}] ${cmd}`),
|
|
400
|
+
onSuccess: () => p.log.success("All commands completed successfully."),
|
|
401
|
+
onError: (cmd, exitCode) =>
|
|
402
|
+
p.log.error(`Command exited with code ${exitCode}: ${cmd}`),
|
|
403
|
+
});
|
|
404
|
+
await runCleanup(inferInputFiles(commands));
|
|
405
|
+
process.exit(success ? 0 : 1);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── Conversion flow ───────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
async function runConversion(
|
|
411
|
+
initialRequest: string,
|
|
412
|
+
config: ViconConfig,
|
|
413
|
+
): Promise<void> {
|
|
414
|
+
const toolSpinner = p.spinner();
|
|
415
|
+
toolSpinner.start("Detecting tools…");
|
|
416
|
+
const ctx = await detectContext();
|
|
417
|
+
toolSpinner.stop("Tools detected.");
|
|
418
|
+
p.log.info(renderToolSummary(ctx));
|
|
419
|
+
|
|
420
|
+
let { result: currentResult, userRequest } = await generateUntilSuccess(
|
|
421
|
+
initialRequest,
|
|
422
|
+
ctx,
|
|
423
|
+
config,
|
|
424
|
+
);
|
|
425
|
+
renderPanels(currentResult);
|
|
426
|
+
|
|
427
|
+
while (true) {
|
|
428
|
+
const action = await p.select({
|
|
429
|
+
message: "What would you like to do?",
|
|
430
|
+
options: [
|
|
431
|
+
{ value: "run", label: "Run all" },
|
|
432
|
+
{
|
|
433
|
+
value: "edit",
|
|
434
|
+
label: "Edit commands",
|
|
435
|
+
hint: "tweak the generated commands",
|
|
436
|
+
},
|
|
437
|
+
{ value: "retry", label: "Retry", hint: "regenerate with same prompt" },
|
|
438
|
+
{
|
|
439
|
+
value: "edit-prompt",
|
|
440
|
+
label: "Edit prompt",
|
|
441
|
+
hint: "modify request and retry",
|
|
442
|
+
},
|
|
443
|
+
{ value: "copy", label: "Copy" },
|
|
444
|
+
{ value: "cancel", label: "Cancel" },
|
|
445
|
+
],
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if (p.isCancel(action) || action === "cancel") {
|
|
449
|
+
p.outro("Cancelled.");
|
|
450
|
+
process.exit(0);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (action === "retry") {
|
|
454
|
+
({ result: currentResult, userRequest } = await generateUntilSuccess(
|
|
455
|
+
userRequest,
|
|
456
|
+
ctx,
|
|
457
|
+
config,
|
|
458
|
+
));
|
|
459
|
+
renderPanels(currentResult);
|
|
460
|
+
} else if (action === "edit-prompt") {
|
|
461
|
+
({ result: currentResult, userRequest } = await handleEditPromptAction(
|
|
462
|
+
userRequest,
|
|
463
|
+
ctx,
|
|
464
|
+
config,
|
|
465
|
+
));
|
|
466
|
+
renderPanels(currentResult);
|
|
467
|
+
} else if (action === "copy") {
|
|
468
|
+
await handleCopyAction(currentResult.commands);
|
|
469
|
+
} else if (action === "edit") {
|
|
470
|
+
currentResult = await handleEditCommandsAction(currentResult);
|
|
471
|
+
renderPanels(currentResult);
|
|
472
|
+
} else if (action === "run") {
|
|
473
|
+
await handleRunAction(currentResult.commands);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
const subcommand = args[0];
|
|
481
|
+
|
|
482
|
+
if (subcommand === "setup") {
|
|
483
|
+
await runSetup();
|
|
484
|
+
process.exit(0);
|
|
485
|
+
} else if (subcommand === "teardown") {
|
|
486
|
+
await runTeardown();
|
|
487
|
+
process.exit(0);
|
|
488
|
+
} else {
|
|
489
|
+
// First non-flag positional arg is the conversion request
|
|
490
|
+
const request = args.find((a) => !a.startsWith("-"));
|
|
491
|
+
|
|
492
|
+
// Config is loaded here so --provider override can be applied
|
|
493
|
+
let config = await getConfig();
|
|
494
|
+
|
|
495
|
+
if (providerOverride) {
|
|
496
|
+
if (config) {
|
|
497
|
+
config = { ...config, defaultProvider: providerOverride };
|
|
498
|
+
} else {
|
|
499
|
+
config = { defaultProvider: providerOverride };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (!config) {
|
|
504
|
+
showBanner();
|
|
505
|
+
p.log.error("No provider configured. Run: vicon setup");
|
|
506
|
+
process.exit(1);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (config.defaultProvider === "cloudflare" && !config.cloudflare) {
|
|
510
|
+
showBanner();
|
|
511
|
+
p.log.error("Cloudflare credentials missing. Run: vicon setup");
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
showBanner();
|
|
516
|
+
|
|
517
|
+
if (!request) {
|
|
518
|
+
p.log.info("Usage: vicon <request> | vicon --help for more");
|
|
519
|
+
process.exit(0);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
await runConversion(request, config);
|
|
523
|
+
}
|
package/src/lib/ai.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { GenerateResult } from "../types.js";
|
|
2
|
+
import type { CloudflareCredentials, ViconConfig } 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): GenerateResult {
|
|
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 { commands?: unknown; explanation?: unknown };
|
|
31
|
+
if (
|
|
32
|
+
!Array.isArray(obj.commands) ||
|
|
33
|
+
!obj.commands.every((c: unknown) => typeof c === "string") ||
|
|
34
|
+
typeof obj.explanation !== "string"
|
|
35
|
+
) {
|
|
36
|
+
throw new ValidationError(
|
|
37
|
+
"Response missing required fields: commands (string[]) and explanation (string)",
|
|
38
|
+
raw,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { commands: obj.commands as string[], explanation: obj.explanation };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function generateWithCloudflare(
|
|
46
|
+
systemPrompt: string,
|
|
47
|
+
userPrompt: string,
|
|
48
|
+
credentials: CloudflareCredentials,
|
|
49
|
+
): Promise<GenerateResult> {
|
|
50
|
+
const response = await fetch(
|
|
51
|
+
`https://api.cloudflare.com/client/v4/accounts/${credentials.accountId}/ai/v1/chat/completions`,
|
|
52
|
+
{
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
Authorization: `Bearer ${credentials.apiToken}`,
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
model: CF_MODEL,
|
|
60
|
+
messages: [
|
|
61
|
+
{ role: "system", content: systemPrompt },
|
|
62
|
+
{ role: "user", content: userPrompt },
|
|
63
|
+
],
|
|
64
|
+
response_format: { type: "json_object" },
|
|
65
|
+
max_tokens: 2048,
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const error = await response.text();
|
|
72
|
+
throw new Error(`Cloudflare API error ${response.status}: ${error}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const data = (await response.json()) as {
|
|
76
|
+
choices?: { message?: { content?: string } }[];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const raw = data.choices?.[0]?.message?.content ?? "";
|
|
80
|
+
return validateResponse(raw);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function generateWithClaude(
|
|
84
|
+
systemPrompt: string,
|
|
85
|
+
userPrompt: string,
|
|
86
|
+
): Promise<GenerateResult> {
|
|
87
|
+
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
|
88
|
+
const proc = Bun.spawn(
|
|
89
|
+
["claude", "--model", CLAUDE_MODEL, "-p", combinedPrompt],
|
|
90
|
+
{
|
|
91
|
+
stdout: "pipe",
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
const raw = (await new Response(proc.stdout).text()).trim();
|
|
95
|
+
return validateResponse(raw);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function generate(
|
|
99
|
+
systemPrompt: string,
|
|
100
|
+
userPrompt: string,
|
|
101
|
+
config: ViconConfig,
|
|
102
|
+
): Promise<GenerateResult> {
|
|
103
|
+
if (config.defaultProvider === "cloudflare") {
|
|
104
|
+
if (!config.cloudflare) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"Cloudflare credentials not configured. Run: vicon setup",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return generateWithCloudflare(systemPrompt, userPrompt, config.cloudflare);
|
|
110
|
+
}
|
|
111
|
+
return generateWithClaude(systemPrompt, userPrompt);
|
|
112
|
+
}
|
|
@@ -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 ViconConfig {
|
|
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: ViconConfig | null | undefined;
|
|
17
|
+
|
|
18
|
+
export async function getConfig(): Promise<ViconConfig | 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 ViconConfig;
|
|
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 ViconConfig;
|
|
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: ViconConfig): 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,28 @@
|
|
|
1
|
+
import type { ToolContext } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export function buildSystemPrompt(ctx: ToolContext): string {
|
|
4
|
+
const ffmpegLine = ctx.ffmpeg.installed
|
|
5
|
+
? `ffmpeg ${ctx.ffmpeg.version ?? "unknown"} | encoders: [${ctx.ffmpeg.encoders.join(", ")}] | decoders: [${ctx.ffmpeg.decoders.join(", ")}]`
|
|
6
|
+
: "ffmpeg: not installed";
|
|
7
|
+
|
|
8
|
+
const magickLine = ctx.magick.installed
|
|
9
|
+
? `magick ${ctx.magick.version ?? "unknown"} | formats: [${ctx.magick.formats.join(", ")}]`
|
|
10
|
+
: "magick: not installed";
|
|
11
|
+
|
|
12
|
+
const environment = `## Environment\n${ffmpegLine}\n${magickLine}`;
|
|
13
|
+
|
|
14
|
+
const rules = `## Rules
|
|
15
|
+
Return ONLY valid JSON in this exact shape: { "commands": string[], "explanation": string }
|
|
16
|
+
- explanation: plain prose only — no shell syntax, no backticks, no code
|
|
17
|
+
- commands: complete, copy-pasteable shell strings — no placeholders, no &&, no loops
|
|
18
|
+
- Only use tools that are listed as installed above
|
|
19
|
+
- Prefer non-destructive output: append _converted to output filenames, use -n flag to avoid overwriting
|
|
20
|
+
- For batch tasks, emit one command per file
|
|
21
|
+
IMPORTANT: Reply with ONLY the JSON object — no markdown fences, no extra text`;
|
|
22
|
+
|
|
23
|
+
return [environment, rules].join("\n\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildUserPrompt(request: string): string {
|
|
27
|
+
return request;
|
|
28
|
+
}
|
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
|
+
}
|
package/src/lib/tools.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
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) return { installed: false, encoders: [], decoders: [] };
|
|
17
|
+
|
|
18
|
+
const versionMatch = versionOut.split("\n")[0]?.match(/ffmpeg version (\S+)/);
|
|
19
|
+
const version = versionMatch?.[1];
|
|
20
|
+
|
|
21
|
+
const encodersOut = await run(["ffmpeg", "-encoders"]);
|
|
22
|
+
const decodersOut = await run(["ffmpeg", "-decoders"]);
|
|
23
|
+
|
|
24
|
+
const encoders = encodersOut
|
|
25
|
+
.split("\n")
|
|
26
|
+
.slice(1)
|
|
27
|
+
.filter((l) => /^ [VAS.]+\s/.test(l))
|
|
28
|
+
.map((l) => l.trim().split(/\s+/)[1] ?? "")
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
|
|
31
|
+
const decoders = decodersOut
|
|
32
|
+
.split("\n")
|
|
33
|
+
.slice(1)
|
|
34
|
+
.filter((l) => /^ [VAS.]+\s/.test(l))
|
|
35
|
+
.map((l) => l.trim().split(/\s+/)[1] ?? "")
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
|
|
38
|
+
return { installed: true, version, encoders, decoders };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function probeMagick(): Promise<ToolContext["magick"]> {
|
|
42
|
+
const versionOut = await run(["magick", "-version"]);
|
|
43
|
+
if (!versionOut) return { installed: false, formats: [] };
|
|
44
|
+
|
|
45
|
+
const versionMatch = versionOut
|
|
46
|
+
.split("\n")[0]
|
|
47
|
+
?.match(/Version: ImageMagick (\S+)/);
|
|
48
|
+
const version = versionMatch?.[1];
|
|
49
|
+
|
|
50
|
+
const formatsOut = await run(["magick", "-list", "format"]);
|
|
51
|
+
|
|
52
|
+
const formats = formatsOut
|
|
53
|
+
.split("\n")
|
|
54
|
+
.filter((l) => /^\s+[A-Z0-9]+\*?\s/.test(l))
|
|
55
|
+
.map((l) => l.trim().split(/\s+/)[0]?.replace("*", "") ?? "")
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
|
|
58
|
+
return { installed: true, version, formats };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function detectContext(): Promise<ToolContext> {
|
|
62
|
+
if (cached) return cached;
|
|
63
|
+
|
|
64
|
+
const [ffmpeg, magick] = await Promise.all([probeFfmpeg(), probeMagick()]);
|
|
65
|
+
cached = { ffmpeg, magick };
|
|
66
|
+
return cached;
|
|
67
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface GenerateResult {
|
|
2
|
+
commands: string[];
|
|
3
|
+
explanation: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ToolContext {
|
|
7
|
+
ffmpeg: {
|
|
8
|
+
installed: boolean;
|
|
9
|
+
version?: string;
|
|
10
|
+
encoders: string[];
|
|
11
|
+
decoders: string[];
|
|
12
|
+
};
|
|
13
|
+
magick: {
|
|
14
|
+
installed: boolean;
|
|
15
|
+
version?: string;
|
|
16
|
+
formats: string[];
|
|
17
|
+
};
|
|
18
|
+
}
|
package/src/ui/banner.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import gradient from "gradient-string";
|
|
2
|
+
import { gradientColors } from "./theme.js";
|
|
3
|
+
|
|
4
|
+
const bannerGradient = gradient([...gradientColors.banner]);
|
|
5
|
+
|
|
6
|
+
const BANNER = `
|
|
7
|
+
|
|
8
|
+
d8,
|
|
9
|
+
\`8P
|
|
10
|
+
|
|
11
|
+
?88 d8P 88b d8888b d8888b 88bd88b
|
|
12
|
+
d88 d8P' 88Pd8P' \`Pd8P' ?88 88P' ?8b
|
|
13
|
+
?8b ,88' d88 88b 88b d88 d88 88P
|
|
14
|
+
\`?888P' d88' \`?888P'\`?8888P'd88' 88b
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Display the ASCII art banner with gradient colors
|
|
22
|
+
*/
|
|
23
|
+
export function showBanner(): void {
|
|
24
|
+
console.log(bannerGradient(BANNER));
|
|
25
|
+
}
|
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
|
|
3
|
+
// Catppuccin Frappe palette
|
|
4
|
+
// https://github.com/catppuccin/catppuccin
|
|
5
|
+
const palette = {
|
|
6
|
+
base: "#303446",
|
|
7
|
+
blue: "#8caaee",
|
|
8
|
+
crust: "#232634",
|
|
9
|
+
flamingo: "#eebebe",
|
|
10
|
+
green: "#a6d189",
|
|
11
|
+
lavender: "#babbf1",
|
|
12
|
+
mantle: "#292c3c",
|
|
13
|
+
maroon: "#ea999c",
|
|
14
|
+
mauve: "#ca9ee6",
|
|
15
|
+
overlay0: "#737994",
|
|
16
|
+
overlay1: "#838ba7",
|
|
17
|
+
overlay2: "#949cbb",
|
|
18
|
+
peach: "#ef9f76",
|
|
19
|
+
pink: "#f4b8e4",
|
|
20
|
+
red: "#e78284",
|
|
21
|
+
rosewater: "#f2d5cf",
|
|
22
|
+
sapphire: "#85c1dc",
|
|
23
|
+
sky: "#99d1db",
|
|
24
|
+
subtext0: "#a5adce",
|
|
25
|
+
subtext1: "#b5bfe2",
|
|
26
|
+
surface0: "#414559",
|
|
27
|
+
surface1: "#51576d",
|
|
28
|
+
surface2: "#626880",
|
|
29
|
+
teal: "#81c8be",
|
|
30
|
+
text: "#c6d0f5",
|
|
31
|
+
yellow: "#e5c890",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
// ANSI 256-color approximations for Catppuccin Frappe
|
|
35
|
+
// These are the closest matches in the 256-color palette
|
|
36
|
+
const ansi = {
|
|
37
|
+
base: 236,
|
|
38
|
+
blue: 111,
|
|
39
|
+
crust: 234,
|
|
40
|
+
flamingo: 217,
|
|
41
|
+
green: 150,
|
|
42
|
+
lavender: 147,
|
|
43
|
+
mantle: 235,
|
|
44
|
+
maroon: 217,
|
|
45
|
+
mauve: 183,
|
|
46
|
+
overlay0: 60,
|
|
47
|
+
overlay1: 103,
|
|
48
|
+
overlay2: 103,
|
|
49
|
+
peach: 216,
|
|
50
|
+
pink: 218,
|
|
51
|
+
red: 210,
|
|
52
|
+
rosewater: 224,
|
|
53
|
+
sapphire: 110,
|
|
54
|
+
sky: 117,
|
|
55
|
+
subtext0: 146,
|
|
56
|
+
subtext1: 146,
|
|
57
|
+
surface0: 59,
|
|
58
|
+
surface1: 59,
|
|
59
|
+
surface2: 60,
|
|
60
|
+
teal: 116,
|
|
61
|
+
text: 189,
|
|
62
|
+
yellow: 223,
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
// Color functions using ANSI 256 colors
|
|
66
|
+
function ansiColor(code: number): (text: string) => string {
|
|
67
|
+
return (text: string) => `\x1b[38;5;${code}m${text}\x1b[0m`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ansiBg(code: number): (text: string) => string {
|
|
71
|
+
return (text: string) => `\x1b[48;5;${code}m${text}\x1b[0m`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Theme colors as functions
|
|
75
|
+
export const frappe = {
|
|
76
|
+
// Base colors
|
|
77
|
+
base: ansiColor(ansi.base),
|
|
78
|
+
|
|
79
|
+
// Background variants
|
|
80
|
+
bg: {
|
|
81
|
+
base: ansiBg(ansi.base),
|
|
82
|
+
surface0: ansiBg(ansi.surface0),
|
|
83
|
+
surface1: ansiBg(ansi.surface1),
|
|
84
|
+
},
|
|
85
|
+
blue: ansiColor(ansi.blue),
|
|
86
|
+
crust: ansiColor(ansi.crust),
|
|
87
|
+
flamingo: ansiColor(ansi.flamingo),
|
|
88
|
+
green: ansiColor(ansi.green),
|
|
89
|
+
lavender: ansiColor(ansi.lavender),
|
|
90
|
+
mantle: ansiColor(ansi.mantle),
|
|
91
|
+
maroon: ansiColor(ansi.maroon),
|
|
92
|
+
mauve: ansiColor(ansi.mauve),
|
|
93
|
+
overlay0: ansiColor(ansi.overlay0),
|
|
94
|
+
overlay1: ansiColor(ansi.overlay1),
|
|
95
|
+
|
|
96
|
+
// Overlay colors
|
|
97
|
+
overlay2: ansiColor(ansi.overlay2),
|
|
98
|
+
peach: ansiColor(ansi.peach),
|
|
99
|
+
pink: ansiColor(ansi.pink),
|
|
100
|
+
red: ansiColor(ansi.red),
|
|
101
|
+
// Primary accent colors
|
|
102
|
+
rosewater: ansiColor(ansi.rosewater),
|
|
103
|
+
sapphire: ansiColor(ansi.sapphire),
|
|
104
|
+
sky: ansiColor(ansi.sky),
|
|
105
|
+
subtext0: ansiColor(ansi.subtext0),
|
|
106
|
+
subtext1: ansiColor(ansi.subtext1),
|
|
107
|
+
surface0: ansiColor(ansi.surface0),
|
|
108
|
+
surface1: ansiColor(ansi.surface1),
|
|
109
|
+
|
|
110
|
+
// Surface colors
|
|
111
|
+
surface2: ansiColor(ansi.surface2),
|
|
112
|
+
teal: ansiColor(ansi.teal),
|
|
113
|
+
|
|
114
|
+
// Text colors
|
|
115
|
+
text: ansiColor(ansi.text),
|
|
116
|
+
yellow: ansiColor(ansi.yellow),
|
|
117
|
+
} as const;
|
|
118
|
+
|
|
119
|
+
// Semantic aliases for common use cases
|
|
120
|
+
export const theme = {
|
|
121
|
+
accent: frappe.flamingo,
|
|
122
|
+
|
|
123
|
+
// Diff colors
|
|
124
|
+
added: frappe.green,
|
|
125
|
+
body: frappe.subtext1,
|
|
126
|
+
dim: frappe.surface2,
|
|
127
|
+
error: frappe.red,
|
|
128
|
+
|
|
129
|
+
// Text
|
|
130
|
+
heading: frappe.text,
|
|
131
|
+
info: frappe.blue,
|
|
132
|
+
modified: frappe.yellow,
|
|
133
|
+
muted: frappe.overlay1,
|
|
134
|
+
|
|
135
|
+
// UI elements
|
|
136
|
+
primary: frappe.mauve,
|
|
137
|
+
removed: frappe.red,
|
|
138
|
+
secondary: frappe.pink,
|
|
139
|
+
subtle: frappe.subtext0,
|
|
140
|
+
// Status colors
|
|
141
|
+
success: frappe.green,
|
|
142
|
+
warning: frappe.yellow,
|
|
143
|
+
} as const;
|
|
144
|
+
|
|
145
|
+
// Gradient colors for banner (hex values for gradient-string)
|
|
146
|
+
export const gradientColors = {
|
|
147
|
+
banner: [palette.mauve, palette.pink, palette.flamingo],
|
|
148
|
+
error: [palette.red, palette.maroon],
|
|
149
|
+
success: [palette.green, palette.teal],
|
|
150
|
+
} as const;
|
|
151
|
+
|
|
152
|
+
// Box border color (hex for boxen)
|
|
153
|
+
export const boxColors = {
|
|
154
|
+
default: palette.surface2,
|
|
155
|
+
error: palette.red,
|
|
156
|
+
info: palette.blue,
|
|
157
|
+
primary: palette.mauve,
|
|
158
|
+
success: palette.green,
|
|
159
|
+
} as const;
|
|
160
|
+
|
|
161
|
+
// Re-export picocolors for basic formatting
|
|
162
|
+
export { pc };
|