fconvert 0.1.0 → 0.1.1

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 CHANGED
@@ -1,4 +1,4 @@
1
- # convert
1
+ # fconvert
2
2
 
3
3
  Fast local CLI conversion tool with graph-based anything-to-anything routing.
4
4
 
@@ -12,13 +12,16 @@ bun run convert formats
12
12
  ## Commands
13
13
 
14
14
  ```bash
15
- bun run convert <input> <output>
15
+ bun run convert <input> [output]
16
16
  bun run convert route <input> --to <format>
17
17
  bun run convert formats
18
18
  bun run convert handlers
19
19
  bun run convert doctor
20
20
  ```
21
21
 
22
+ If you omit output and `--to`, an interactive Bubble Tea fuzzy picker appears so you can select the output format.
23
+ The output file defaults to the input basename with the selected extension.
24
+
22
25
  ## Useful flags
23
26
 
24
27
  ```bash
@@ -42,6 +45,7 @@ bun run build
42
45
  ```
43
46
 
44
47
  Compiled binary is written to `dist/convert`.
48
+ The Bubble Tea picker helper is written to `dist/fconvert-picker`.
45
49
 
46
50
  ## Project structure
47
51
 
package/dist/convert CHANGED
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fconvert",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Fast local CLI conversion tool with graph-based anything-to-anything routing",
5
5
  "type": "module",
6
6
  "main": "src/cli/main.ts",
@@ -9,9 +9,12 @@
9
9
  "convert": "bun run src/cli/main.ts",
10
10
  "test": "bun test",
11
11
  "typecheck": "bunx tsc --noEmit",
12
- "build": "bun build src/cli/main.ts --compile --outfile dist/convert"
12
+ "build:cli": "bun build src/cli/main.ts --compile --outfile dist/convert",
13
+ "build:picker": "go build -C tools/format-picker -o ../../dist/fconvert-picker .",
14
+ "build": "bun run build:cli && bun run build:picker"
13
15
  },
14
16
  "bin": {
17
+ "fconvert": "dist/convert",
15
18
  "convert": "dist/convert"
16
19
  },
17
20
  "devDependencies": {
@@ -20,6 +23,12 @@
20
23
  "peerDependencies": {
21
24
  "typescript": "^5"
22
25
  },
23
- "keywords": ["convert", "converter", "ffmpeg", "pandoc", "cli"],
26
+ "keywords": [
27
+ "convert",
28
+ "converter",
29
+ "ffmpeg",
30
+ "pandoc",
31
+ "cli"
32
+ ],
24
33
  "license": "MIT"
25
34
  }
@@ -4,7 +4,8 @@ import { BundleResolver } from "../../bundle/resolve.ts";
4
4
  import { buildPlanOptions } from "../../core/config.ts";
5
5
  import { CliError, ExitCode } from "../../core/errors.ts";
6
6
  import { ConsoleLogger } from "../../core/logger.ts";
7
- import type { ConvertSummary } from "../../core/types.ts";
7
+ import type { ConvertSummary, FileFormat } from "../../core/types.ts";
8
+ import { buildDefaultOutputPath, pickOutputFormatInteractive } from "../output-picker.ts";
8
9
  import { ConversionEngine } from "../../executor/executor.ts";
9
10
  import { detectInputFormat, resolveOutputFormat } from "../../formats/detect.ts";
10
11
  import { FormatRegistry } from "../../formats/registry.ts";
@@ -42,24 +43,30 @@ export async function runConvertCommand(input: {
42
43
 
43
44
  const inputPath = input.positionals[0];
44
45
  const positionalOutput = input.positionals[1];
45
- const outputPath = input.options.output ?? positionalOutput;
46
+ const explicitOutputPath = input.options.output ?? positionalOutput;
46
47
 
47
48
  if (!inputPath) {
48
- throw new CliError("Usage: convert <input> <output> [--from fmt] [--to fmt]", ExitCode.InvalidArgs);
49
+ throw new CliError(
50
+ "Usage: convert <input> [output] [--from fmt] [--to fmt]",
51
+ ExitCode.InvalidArgs,
52
+ );
49
53
  }
50
54
 
51
- if (!outputPath) {
52
- throw new CliError("Missing output path. Provide <output> or --output", ExitCode.InvalidArgs);
55
+ const formats = new FormatRegistry();
56
+ const inputFormat = detectInputFormat(inputPath, input.options.from, formats);
57
+ let outputFormat: FileFormat;
58
+ if (explicitOutputPath || input.options.to) {
59
+ outputFormat = resolveOutputFormat(explicitOutputPath, input.options.to, formats);
60
+ } else {
61
+ outputFormat = await pickOutputFormatInteractive(formats, inputPath);
53
62
  }
54
63
 
64
+ const outputPath = explicitOutputPath ?? buildDefaultOutputPath(inputPath, outputFormat);
65
+
55
66
  if ((await outputExists(outputPath)) && !input.options.force) {
56
67
  throw new CliError(`Output exists: ${outputPath}. Use --force to overwrite.`, ExitCode.InvalidArgs);
57
68
  }
58
69
 
59
- const formats = new FormatRegistry();
60
- const inputFormat = detectInputFormat(inputPath, input.options.from, formats);
61
- const outputFormat = resolveOutputFormat(outputPath, input.options.to, formats);
62
-
63
70
  const bundle = new BundleResolver();
64
71
  const handlers = new HandlerRegistry();
65
72
  const engine = new ConversionEngine(formats, handlers, bundle, logger);
package/src/cli/main.ts CHANGED
@@ -9,11 +9,11 @@ import { CliError, ExitCode } from "../core/errors.ts";
9
9
  import { parseArgs } from "./parse.ts";
10
10
 
11
11
  function printUsage(): void {
12
- console.log("convert <input> <output> [--from fmt] [--to fmt]");
13
- console.log("convert route <input> --to <format>");
14
- console.log("convert formats");
15
- console.log("convert handlers");
16
- console.log("convert doctor");
12
+ console.log("fconvert <input> [output] [--from fmt] [--to fmt]");
13
+ console.log("fconvert route <input> --to <format>");
14
+ console.log("fconvert formats");
15
+ console.log("fconvert handlers");
16
+ console.log("fconvert doctor");
17
17
  }
18
18
 
19
19
  async function main(): Promise<void> {
@@ -0,0 +1,135 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, extname, join } from "node:path";
5
+ import { CliError, ExitCode } from "../core/errors.ts";
6
+ import type { FileFormat } from "../core/types.ts";
7
+ import { FormatRegistry } from "../formats/registry.ts";
8
+
9
+ interface PickerPayload {
10
+ prompt: string;
11
+ query: string;
12
+ options: Array<{
13
+ id: string;
14
+ name: string;
15
+ extension: string;
16
+ }>;
17
+ }
18
+
19
+ function withExecutableSuffix(path: string): string {
20
+ if (process.platform === "win32") {
21
+ return path.endsWith(".exe") ? path : `${path}.exe`;
22
+ }
23
+ return path;
24
+ }
25
+
26
+ async function pathExists(path: string): Promise<boolean> {
27
+ try {
28
+ await access(path, fsConstants.F_OK);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async function resolvePickerBinary(): Promise<string | undefined> {
36
+ const envPath = process.env.FCONVERT_PICKER_BIN;
37
+ if (envPath && (await pathExists(envPath))) {
38
+ return envPath;
39
+ }
40
+
41
+ const argv0Dir = dirname(Bun.argv[0] ?? process.cwd());
42
+ const argv1Dir = dirname(Bun.argv[1] ?? process.cwd());
43
+
44
+ const candidates = [
45
+ join(process.cwd(), "dist", "fconvert-picker"),
46
+ join(argv0Dir, "fconvert-picker"),
47
+ join(argv0Dir, "dist", "fconvert-picker"),
48
+ join(argv1Dir, "..", "dist", "fconvert-picker"),
49
+ ].map(withExecutableSuffix);
50
+
51
+ for (const candidate of candidates) {
52
+ if (await pathExists(candidate)) {
53
+ return candidate;
54
+ }
55
+ }
56
+
57
+ const inPath = Bun.which("fconvert-picker");
58
+ if (inPath) {
59
+ return inPath;
60
+ }
61
+
62
+ return undefined;
63
+ }
64
+
65
+ export function buildDefaultOutputPath(inputPath: string, outputFormat: FileFormat): string {
66
+ const inputDirectory = dirname(inputPath);
67
+ const inputName = basename(inputPath);
68
+ const currentExtension = extname(inputName);
69
+ const baseName = currentExtension.length > 0 ? inputName.slice(0, -currentExtension.length) : inputName;
70
+ return join(inputDirectory, `${baseName}.${outputFormat.extension}`);
71
+ }
72
+
73
+ export async function pickOutputFormatInteractive(
74
+ registry: FormatRegistry,
75
+ inputPath: string,
76
+ ): Promise<FileFormat> {
77
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
78
+ throw new CliError(
79
+ "No output format provided and terminal is not interactive. Pass <output> or --to.",
80
+ ExitCode.InvalidArgs,
81
+ );
82
+ }
83
+
84
+ const pickerBinary = await resolvePickerBinary();
85
+ if (!pickerBinary) {
86
+ throw new CliError(
87
+ "Output picker binary not found. Build it with 'bun run build:picker' or pass --to.",
88
+ ExitCode.EnvironmentError,
89
+ );
90
+ }
91
+
92
+ const tempRoot = await mkdtemp(join(tmpdir(), "fconvert-picker-"));
93
+ const optionsPath = join(tempRoot, "options.json");
94
+ const resultPath = join(tempRoot, "result.txt");
95
+
96
+ const payload: PickerPayload = {
97
+ prompt: "output format",
98
+ query: extname(inputPath).replace(/^\./, ""),
99
+ options: registry.all().map((format) => ({
100
+ id: format.id,
101
+ name: format.name,
102
+ extension: format.extension,
103
+ })),
104
+ };
105
+
106
+ try {
107
+ await writeFile(optionsPath, JSON.stringify(payload), "utf8");
108
+
109
+ const processHandle = Bun.spawn(
110
+ [pickerBinary, "--options", optionsPath, "--result", resultPath],
111
+ {
112
+ stdin: "inherit",
113
+ stdout: "inherit",
114
+ stderr: "inherit",
115
+ },
116
+ );
117
+
118
+ const exitCode = await processHandle.exited;
119
+ if (exitCode === 130) {
120
+ throw new CliError("Output format selection cancelled", ExitCode.InvalidArgs);
121
+ }
122
+ if (exitCode !== 0) {
123
+ throw new CliError(`Output picker exited with code ${exitCode}`, ExitCode.InternalError);
124
+ }
125
+
126
+ const chosenId = (await readFile(resultPath, "utf8")).trim();
127
+ const format = registry.getById(chosenId);
128
+ if (!format) {
129
+ throw new CliError(`Picker returned unknown format: ${chosenId}`, ExitCode.InternalError);
130
+ }
131
+ return format;
132
+ } finally {
133
+ await rm(tempRoot, { recursive: true, force: true });
134
+ }
135
+ }
@@ -0,0 +1,15 @@
1
+ import { expect, test } from "bun:test";
2
+ import { buildDefaultOutputPath } from "../../src/cli/output-picker.ts";
3
+
4
+ test("buildDefaultOutputPath replaces extension with selected format", () => {
5
+ const path = buildDefaultOutputPath("/tmp/sample.input.txt", {
6
+ id: "json",
7
+ name: "JSON",
8
+ extension: "json",
9
+ mime: "application/json",
10
+ category: ["data", "text"],
11
+ aliases: [],
12
+ });
13
+
14
+ expect(path).toBe("/tmp/sample.input.json");
15
+ });
@@ -0,0 +1,24 @@
1
+ module github.com/timmy6942025/convert/tools/format-picker
2
+
3
+ go 1.22
4
+
5
+ require github.com/charmbracelet/bubbletea v1.3.4
6
+
7
+ require (
8
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
9
+ github.com/charmbracelet/lipgloss v1.0.0 // indirect
10
+ github.com/charmbracelet/x/ansi v0.8.0 // indirect
11
+ github.com/charmbracelet/x/term v0.2.1 // indirect
12
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
13
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
14
+ github.com/mattn/go-isatty v0.0.20 // indirect
15
+ github.com/mattn/go-localereader v0.0.1 // indirect
16
+ github.com/mattn/go-runewidth v0.0.16 // indirect
17
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
18
+ github.com/muesli/cancelreader v0.2.2 // indirect
19
+ github.com/muesli/termenv v0.15.2 // indirect
20
+ github.com/rivo/uniseg v0.4.7 // indirect
21
+ golang.org/x/sync v0.11.0 // indirect
22
+ golang.org/x/sys v0.30.0 // indirect
23
+ golang.org/x/text v0.3.8 // indirect
24
+ )
@@ -0,0 +1,37 @@
1
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
2
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3
+ github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
4
+ github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
5
+ github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
6
+ github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
7
+ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
8
+ github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
9
+ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
10
+ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
11
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
12
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
13
+ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
14
+ github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
15
+ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
16
+ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
17
+ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
18
+ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
19
+ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
20
+ github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
21
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
22
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
23
+ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
24
+ github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
25
+ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
26
+ github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
27
+ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
28
+ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
29
+ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
30
+ golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
31
+ golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
32
+ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
33
+ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
34
+ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
35
+ golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
36
+ golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
37
+ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
@@ -0,0 +1,274 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "flag"
6
+ "fmt"
7
+ "os"
8
+ "sort"
9
+ "strings"
10
+
11
+ tea "github.com/charmbracelet/bubbletea"
12
+ )
13
+
14
+ type option struct {
15
+ ID string `json:"id"`
16
+ Name string `json:"name"`
17
+ Extension string `json:"extension"`
18
+ }
19
+
20
+ type payload struct {
21
+ Prompt string `json:"prompt"`
22
+ Query string `json:"query"`
23
+ Options []option `json:"options"`
24
+ }
25
+
26
+ type scoredOption struct {
27
+ option option
28
+ score int
29
+ }
30
+
31
+ type model struct {
32
+ prompt string
33
+ query string
34
+ all []option
35
+ filtered []option
36
+ cursor int
37
+ selected *option
38
+ cancelled bool
39
+ width int
40
+ height int
41
+ }
42
+
43
+ func (m model) Init() tea.Cmd {
44
+ return nil
45
+ }
46
+
47
+ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
48
+ switch msg := msg.(type) {
49
+ case tea.WindowSizeMsg:
50
+ m.width = msg.Width
51
+ m.height = msg.Height
52
+ return m, nil
53
+ case tea.KeyMsg:
54
+ switch msg.String() {
55
+ case "ctrl+c", "esc":
56
+ m.cancelled = true
57
+ return m, tea.Quit
58
+ case "enter":
59
+ if len(m.filtered) == 0 {
60
+ return m, nil
61
+ }
62
+ selected := m.filtered[m.cursor]
63
+ m.selected = &selected
64
+ return m, tea.Quit
65
+ case "up", "k", "ctrl+p":
66
+ if m.cursor > 0 {
67
+ m.cursor--
68
+ }
69
+ return m, nil
70
+ case "down", "j", "ctrl+n":
71
+ if m.cursor < len(m.filtered)-1 {
72
+ m.cursor++
73
+ }
74
+ return m, nil
75
+ case "backspace", "ctrl+h":
76
+ if len(m.query) == 0 {
77
+ return m, nil
78
+ }
79
+ m.query = m.query[:len(m.query)-1]
80
+ m.refilter()
81
+ return m, nil
82
+ }
83
+
84
+ if msg.Type == tea.KeyRunes {
85
+ m.query += msg.String()
86
+ m.refilter()
87
+ }
88
+ }
89
+
90
+ return m, nil
91
+ }
92
+
93
+ func (m model) View() string {
94
+ var builder strings.Builder
95
+ builder.WriteString(fmt.Sprintf("%s > %s\n", m.prompt, m.query))
96
+
97
+ if len(m.filtered) == 0 {
98
+ builder.WriteString(" no matches\n")
99
+ builder.WriteString(" enter to keep typing, esc to cancel")
100
+ return builder.String()
101
+ }
102
+
103
+ maxRows := 8
104
+ if m.height > 6 {
105
+ maxRows = m.height - 4
106
+ }
107
+ if maxRows < 4 {
108
+ maxRows = 4
109
+ }
110
+ if maxRows > len(m.filtered) {
111
+ maxRows = len(m.filtered)
112
+ }
113
+
114
+ start := 0
115
+ if m.cursor >= maxRows {
116
+ start = m.cursor - maxRows + 1
117
+ }
118
+
119
+ end := start + maxRows
120
+ if end > len(m.filtered) {
121
+ end = len(m.filtered)
122
+ }
123
+
124
+ for index := start; index < end; index++ {
125
+ prefix := " "
126
+ if index == m.cursor {
127
+ prefix = "> "
128
+ }
129
+ item := m.filtered[index]
130
+ builder.WriteString(fmt.Sprintf("%s%-10s .%-6s %s\n", prefix, item.ID, item.Extension, item.Name))
131
+ }
132
+
133
+ builder.WriteString(" enter select esc cancel")
134
+ return builder.String()
135
+ }
136
+
137
+ func (m *model) refilter() {
138
+ query := strings.ToLower(strings.TrimSpace(m.query))
139
+ scored := make([]scoredOption, 0, len(m.all))
140
+
141
+ for _, item := range m.all {
142
+ score := fuzzyScore(query, item)
143
+ if score < 0 {
144
+ continue
145
+ }
146
+ scored = append(scored, scoredOption{option: item, score: score})
147
+ }
148
+
149
+ sort.SliceStable(scored, func(i, j int) bool {
150
+ if scored[i].score == scored[j].score {
151
+ return scored[i].option.ID < scored[j].option.ID
152
+ }
153
+ return scored[i].score > scored[j].score
154
+ })
155
+
156
+ m.filtered = make([]option, 0, len(scored))
157
+ for _, item := range scored {
158
+ m.filtered = append(m.filtered, item.option)
159
+ }
160
+
161
+ if m.cursor >= len(m.filtered) {
162
+ m.cursor = len(m.filtered) - 1
163
+ }
164
+ if m.cursor < 0 {
165
+ m.cursor = 0
166
+ }
167
+ }
168
+
169
+ func fuzzyScore(query string, item option) int {
170
+ if query == "" {
171
+ return 1
172
+ }
173
+
174
+ candidate := strings.ToLower(item.ID + " " + item.Extension + " " + item.Name)
175
+ queryRunes := []rune(query)
176
+ candidateRunes := []rune(candidate)
177
+
178
+ score := 0
179
+ queryIndex := 0
180
+ streak := 0
181
+ lastMatch := -2
182
+
183
+ for index, value := range candidateRunes {
184
+ if queryIndex >= len(queryRunes) {
185
+ break
186
+ }
187
+ if value != queryRunes[queryIndex] {
188
+ continue
189
+ }
190
+
191
+ score += 10
192
+ if index == lastMatch+1 {
193
+ streak++
194
+ score += 4 * streak
195
+ } else {
196
+ streak = 1
197
+ }
198
+ lastMatch = index
199
+ queryIndex++
200
+ }
201
+
202
+ if queryIndex != len(queryRunes) {
203
+ return -1
204
+ }
205
+
206
+ score -= len(candidateRunes) / 6
207
+ if strings.HasPrefix(strings.ToLower(item.ID), query) {
208
+ score += 15
209
+ }
210
+ return score
211
+ }
212
+
213
+ func readPayload(path string) (payload, error) {
214
+ raw, err := os.ReadFile(path)
215
+ if err != nil {
216
+ return payload{}, err
217
+ }
218
+
219
+ var data payload
220
+ if err := json.Unmarshal(raw, &data); err != nil {
221
+ return payload{}, err
222
+ }
223
+
224
+ if data.Prompt == "" {
225
+ data.Prompt = "output format"
226
+ }
227
+ return data, nil
228
+ }
229
+
230
+ func main() {
231
+ optionsPath := flag.String("options", "", "path to JSON options payload")
232
+ resultPath := flag.String("result", "", "path to output selected id")
233
+ flag.Parse()
234
+
235
+ if *optionsPath == "" || *resultPath == "" {
236
+ fmt.Fprintln(os.Stderr, "usage: fconvert-picker --options <file> --result <file>")
237
+ os.Exit(2)
238
+ }
239
+
240
+ data, err := readPayload(*optionsPath)
241
+ if err != nil {
242
+ fmt.Fprintln(os.Stderr, err)
243
+ os.Exit(1)
244
+ }
245
+
246
+ picker := model{
247
+ prompt: data.Prompt,
248
+ query: data.Query,
249
+ all: data.Options,
250
+ }
251
+ picker.refilter()
252
+
253
+ program := tea.NewProgram(picker)
254
+ result, err := program.Run()
255
+ if err != nil {
256
+ fmt.Fprintln(os.Stderr, err)
257
+ os.Exit(1)
258
+ }
259
+
260
+ finalModel, ok := result.(model)
261
+ if !ok {
262
+ fmt.Fprintln(os.Stderr, "unexpected picker model type")
263
+ os.Exit(1)
264
+ }
265
+
266
+ if finalModel.cancelled || finalModel.selected == nil {
267
+ os.Exit(130)
268
+ }
269
+
270
+ if err := os.WriteFile(*resultPath, []byte(finalModel.selected.ID+"\n"), 0o644); err != nil {
271
+ fmt.Fprintln(os.Stderr, err)
272
+ os.Exit(1)
273
+ }
274
+ }