commit 915b1e72cd4b60032650a26b3dedf8ab7f9996c6 Author: Amolith Date: Fri Jan 9 19:05:48 2026 -0700 feat: initial implementation CLI that accepts text on stdin, sends it to an LLM for transformation based on user directions, and prints the result to stdout. - glint for CLI argument parsing - TOML config from ~/.config/garble/config.toml - Provider discovery from catwalk.charm.sh - Support for OpenAI, Anthropic, Gemini via starlet - Custom OpenAI-compatible endpoints Assisted-by: Claude Opus 4.5 via Crush diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..599be4eb9294fa93ae3e22fc8d4e3828b74d8dd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..b5a8568e04e4083f5cc5b5070524f6695eca4246 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# AGENTS.md + +Garble is a CLI that accepts text on stdin, sends it to an LLM for transformation based on user directions, and prints the result to stdout. Single request/response—no conversation history. + +## Commands + +```sh +make build # Build the project +make test # Run tests +make check # Check formatting (CI-style) +make format # Auto-format source files +make install # Install to ~/.local/bin (override with PREFIX=) +make clean # Remove build artifacts +``` + +## Architecture + +``` +stdin → garble.gleam → provider dispatch → stdout + ↓ + config.gleam (TOML from ~/.config/garble/config.toml) + ↓ + providers.gleam (fetches provider list from catwalk.charm.sh) + ↓ + openai/anthropic/gemini via starlet, or openai_compat.gleam for custom endpoints +``` + +**Flow:** +1. CLI args parsed with glint, merged with config file (CLI wins) +2. stdin read entirely into memory +3. `prompts.gleam` wraps input in `` tags, directions in `` tags +4. Request sent to provider; response code block extracted +5. Output printed to stdout + +**Module responsibilities:** +- `garble.gleam` — Entry point, CLI definition, provider dispatch, API key resolution +- `config.gleam` — TOML config loading from XDG paths, CLI/config merging +- `providers.gleam` — Fetches provider/model list from remote API, validation +- `prompts.gleam` — System prompt, user message construction, code block extraction +- `openai_compat.gleam` — Manual HTTP client for OpenAI-compatible endpoints (when starlet doesn't apply) + +## Key Libraries + +| Package | Purpose | +|---------|---------| +| starlet | LLM API client (OpenAI, Anthropic, Gemini) | +| glint | CLI arg/flag parsing | +| tom | TOML parsing | +| shellout | Running shell commands (for `api_key_cmd`) | +| stdin | Reading from stdin | +| gleescript | Bundling into escript for installation | + +These are documented on Context7 under their respective org/project paths. + +## Conventions + +- **File size limit:** Keep files under 200 lines. If approaching that, split by responsibility. +- **Stdlib first:** Prefer gleam_stdlib and official gleam-lang packages. Use community packages when stdlib lacks functionality. Never use raw Erlang/Elixir FFI without a Gleam wrapper. +- **Error handling:** Use `Result` types throughout. The `halt/1` FFI call exits with status code on fatal errors. +- **Config precedence:** CLI flags → config file → environment variables + +## Testing + +Tests use gleeunit. Test files mirror source structure in `test/`. Functions ending in `_test` are automatically discovered. + +The `prompts_test.gleam` demonstrates the pattern: test public functions, use `should` assertions, keep tests focused on one behavior each. + +## Gotchas + +- **External halt:** `garble.gleam` uses `@external(erlang, "erlang", "halt")` for non-zero exit codes since Gleam lacks this natively. +- **Provider list is remote:** `providers.gleam` fetches from `https://catwalk.charm.sh/v2/providers` at runtime—network errors are possible. +- **Code block extraction:** The system prompt instructs models to wrap output in fenced code blocks; `prompts.extract_code_block` strips them. If the model doesn't comply, raw output passes through. +- **API key resolution order:** `api_key_cmd` (shell command) → `api_key` (literal) → environment variable from provider config diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..206a3a0ed098f99ae37e51d1a3b7c2761ecb798f --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: build test check format install clean help + +PREFIX ?= $(HOME)/.local + +build: + gleam build + +test: + gleam test + +check: + gleam format --check + +format: + gleam format + +install: build + gleam run -m gleescript -- --out=$(PREFIX)/bin + +clean: + rm -rf build + +help: + @echo "Usage: make [target]" + @echo "" + @echo "Targets:" + @echo " build Build the project" + @echo " test Run tests" + @echo " check Check formatting" + @echo " format Format source files" + @echo " install Install to PREFIX/bin (default: ~/.local/bin)" + @echo " clean Remove build artifacts" + @echo " help Show this help" + @echo "" + @echo "Variables:" + @echo " PREFIX Installation prefix (default: ~/.local)" diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c3da200be17faa1aa6ec4a5b533fb4a9e68d3460 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ + + +# garble + +[![Package Version](https://img.shields.io/hexpm/v/garble)](https://hex.pm/packages/garble) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/garble/) +[![REUSE status](https://api.reuse.software/badge/git.secluded.site/garble)](https://api.reuse.software/info/git.secluded.site/garble) +[![Liberapay donation status](https://img.shields.io/liberapay/receives/Amolith.svg?logo=liberapay)](https://liberapay.com/Amolith/) + +Transform stdin with an LLM. Pipe text in, get transformed text out. + +## tl;dr + +```bash +# Fix typos and grammar +echo "teh quikc brown fox" | garble --directions "fix typos" + +# Translate +cat letter.txt | garble --directions "translate to Pirate" + +# Reformat +pbpaste | garble --directions "convert to markdown table" | pbcopy +``` + +## Installation + +```bash +# Clone and install to ~/.local/bin +git clone https://git.secluded.site/garble +cd garble +make install + +# Or install elsewhere +make install PREFIX=/usr/local +``` + +Requires Erlang/OTP and the Gleam toolchain. + +## Usage + +```bash +garble [--provider PROVIDER] [--model MODEL] [--directions "..."] +``` + +All flags are optional if configured in `~/.config/garble/config.toml`. + +### Flags + +- `--provider` — Provider ID (e.g. `openai`, `anthropic`, `google`) +- `--model` — Model ID (e.g. `gpt-4o`, `claude-3-opus`, `gemini-1.5-pro`) +- `--directions` — Instructions for how to transform the input + +### Configuration + +Create `~/.config/garble/config.toml`: + +```toml +provider = "anthropic" +model = "claude-sonnet-4-20250514" +directions = "fix grammar and spelling" + +# API key options (in order of precedence): +# 1. Run a command to get the key +api_key_cmd = "op read 'op://Private/Anthropic/credential'" + +# 2. Or set it directly (not recommended) +# api_key = "sk-..." +``` + +If neither `api_key_cmd` nor `api_key` is set, garble falls back to the +provider's environment variable (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). + +CLI flags override config file values. + +## Contributions + +Patch requests are in [amolith/llm-projects] on [pr.pico.sh]. You don't +need a new account to contribute, you don't need to fork this repo, you +don't need to fiddle with `git send-email`, you don't need to faff with +your email client to get `git request-pull` working... + +You just need: + +- Git +- SSH +- An SSH key + +```sh +# Clone this repo, make your changes, and commit them +# Create a new patch request with +git format-patch origin/main --stdout | ssh pr.pico.sh pr create amolith/llm-projects +# After potential feedback, submit a revision to an existing patch request with +git format-patch origin/main --stdout | ssh pr.pico.sh pr add {prID} +# List patch requests +ssh pr.pico.sh pr ls amolith/llm-projects +``` + +See "How do Patch Requests work?" on [pr.pico.sh]'s home page for a more +complete example workflow. + +[amolith/llm-projects]: https://pr.pico.sh/r/amolith/llm-projects +[pr.pico.sh]: https://pr.pico.sh + +## License + +AGPL-3.0-or-later diff --git a/crush.json b/crush.json new file mode 100644 index 0000000000000000000000000000000000000000..e13a3828cacf4d9319e88aff5e81168f1567dc9d --- /dev/null +++ b/crush.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://charm.land/crush.json", + "lsp": { + "gleam": { + "command": "gleam", + "args": ["lsp"] + } + } +} diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000000000000000000000000000000000000..3f62e93f164e112ca3fa15f1a5dc0867e15f10c9 --- /dev/null +++ b/gleam.toml @@ -0,0 +1,36 @@ +name = "garble" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. +description = "Garble stdin to stdout with an LLM" +licences = ["AGPL-3.0-or-later"] +repository = { type = "custom", url = "https://git.secluded.site/garble" } + +[dependencies] +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +stdin = ">= 2.0.2 and < 3.0.0" +gleam_yielder = ">= 1.1.0 and < 2.0.0" +argv = ">= 1.0.2 and < 2.0.0" +glint = ">= 1.2.1 and < 2.0.0" +gleam_httpc = ">= 5.0.0 and < 6.0.0" +gleam_http = ">= 4.3.0 and < 5.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" +starlet = ">= 1.0.1 and < 2.0.0" +envoy = ">= 1.1.0 and < 2.0.0" +tom = ">= 2.0.0 and < 3.0.0" +simplifile = ">= 2.3.2 and < 3.0.0" +filepath = ">= 1.1.2 and < 2.0.0" +shellout = ">= 1.7.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" +gleescript = ">= 1.5.2 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000000000000000000000000000000000000..6974693cece577f27004c316240abd1854be6e7c --- /dev/null +++ b/manifest.toml @@ -0,0 +1,46 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, + { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, + { name = "gleam_stdlib", version = "0.68.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "EEC7E7A18B8A53B7A28B7F0A2198CE53BAFF05D45479E4806C387EDF26DA842D" }, + { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, + { name = "gleescript", version = "1.5.2", build_tools = ["gleam"], requirements = ["argv", "filepath", "gleam_erlang", "gleam_stdlib", "simplifile", "snag", "tom"], otp_app = "gleescript", source = "hex", outer_checksum = "27AC58481742ED29D9B37C506F78958A8AD798750A79ED08C8F8AFBA8F23563B" }, + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" }, + { name = "jscheam", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "jscheam", source = "hex", outer_checksum = "F8825DD6BC0C5B2BBB41A7B01A0D0AC89876B2305DF05CBB92A450E3B7734FC6" }, + { name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" }, + { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, + { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, + { name = "starlet", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_httpc", "gleam_json", "gleam_stdlib", "jscheam"], otp_app = "starlet", source = "hex", outer_checksum = "4E288CB970EFF9BE11EB476A0449A7A9BFAA993BE25CAA7B7E8A0A5B3A76AE5C" }, + { name = "stdin", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "stdin", source = "hex", outer_checksum = "437084939CE094E06D32D07EB19B74473D08895AB817C03550CEDFDBF0DFDC19" }, + { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, +] + +[requirements] +argv = { version = ">= 1.0.2 and < 2.0.0" } +envoy = { version = ">= 1.1.0 and < 2.0.0" } +filepath = { version = ">= 1.1.2 and < 2.0.0" } +gleam_http = { version = ">= 4.3.0 and < 5.0.0" } +gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } +gleescript = { version = ">= 1.5.2 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +glint = { version = ">= 1.2.1 and < 2.0.0" } +shellout = { version = ">= 1.7.0 and < 2.0.0" } +simplifile = { version = ">= 2.3.2 and < 3.0.0" } +starlet = { version = ">= 1.0.1 and < 2.0.0" } +stdin = { version = ">= 2.0.2 and < 3.0.0" } +tom = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/src/config.gleam b/src/config.gleam new file mode 100644 index 0000000000000000000000000000000000000000..34251d2c477e7497196cc4ca769314fa04662ad6 --- /dev/null +++ b/src/config.gleam @@ -0,0 +1,96 @@ +import envoy +import filepath +import gleam/dict.{type Dict} +import gleam/result +import simplifile +import tom.{type Toml} + +pub type Config { + Config( + provider: String, + model: String, + api_key: String, + api_key_cmd: String, + directions: String, + ) +} + +pub fn default() -> Config { + Config(provider: "", model: "", api_key: "", api_key_cmd: "", directions: "") +} + +/// Load config from XDG_CONFIG_HOME/garble/config.toml or ~/.config/garble/config.toml +pub fn load() -> Config { + case config_path() { + Error(_) -> default() + Ok(path) -> + case simplifile.read(path) { + Error(_) -> default() + Ok(content) -> + case tom.parse(content) { + Error(_) -> default() + Ok(parsed) -> parse_config(parsed) + } + } + } +} + +fn config_path() -> Result(String, Nil) { + let config_dir = case envoy.get("XDG_CONFIG_HOME") { + Ok(xdg) -> xdg + Error(_) -> + case envoy.get("HOME") { + Ok(home) -> filepath.join(home, ".config") + Error(_) -> "" + } + } + + case config_dir { + "" -> Error(Nil) + dir -> { + let path = filepath.join(dir, "garble/config.toml") + case simplifile.is_file(path) { + Ok(True) -> Ok(path) + _ -> Error(Nil) + } + } + } +} + +fn parse_config(parsed: Dict(String, Toml)) -> Config { + Config( + provider: get_string(parsed, "provider"), + model: get_string(parsed, "model"), + api_key: get_string(parsed, "api_key"), + api_key_cmd: get_string(parsed, "api_key_cmd"), + directions: get_string(parsed, "directions"), + ) +} + +fn get_string(parsed: Dict(String, Toml), key: String) -> String { + tom.get_string(parsed, [key]) + |> result.unwrap("") +} + +/// Merge CLI flags over config values. CLI takes precedence when non-empty. +pub fn merge( + cfg: Config, + cli_provider cli_provider: String, + cli_model cli_model: String, + cli_directions cli_directions: String, +) -> Config { + Config( + provider: prefer_nonempty(cli_provider, cfg.provider), + model: prefer_nonempty(cli_model, cfg.model), + api_key: cfg.api_key, + api_key_cmd: cfg.api_key_cmd, + directions: prefer_nonempty(cli_directions, cfg.directions), + ) +} + +fn prefer_nonempty(cli: String, fallback: String) -> String { + case cli { + "" -> fallback + val -> val + } +} diff --git a/src/garble.gleam b/src/garble.gleam new file mode 100644 index 0000000000000000000000000000000000000000..bc0a78881a395fa8d45d381058f202b13e9e9b16 --- /dev/null +++ b/src/garble.gleam @@ -0,0 +1,233 @@ +import argv +import config +import envoy +import gleam/int +import gleam/io +import gleam/option.{None, Some} +import gleam/result +import gleam/string +import gleam/yielder +import glint +import openai_compat +import prompts +import providers.{type Provider} +import shellout +import starlet +import starlet/anthropic +import starlet/gemini +import starlet/openai +import stdin + +@external(erlang, "erlang", "halt") +fn halt(status: Int) -> Nil + +pub fn main() { + glint.new() + |> glint.with_name("garble") + |> glint.pretty_help(glint.default_pretty_help()) + |> glint.add(at: [], do: garble_command()) + |> glint.run(argv.load().arguments) +} + +fn garble_command() -> glint.Command(Nil) { + use <- glint.command_help("Transform stdin with an LLM") + use directions <- glint.flag( + glint.string_flag("directions") + |> glint.flag_default("") + |> glint.flag_help("Directions for how to transform the input"), + ) + use model <- glint.flag( + glint.string_flag("model") + |> glint.flag_default("") + |> glint.flag_help("Model to use (e.g. gpt-4o, claude-3-opus)"), + ) + use provider <- glint.flag( + glint.string_flag("provider") + |> glint.flag_default("") + |> glint.flag_help("Provider (e.g. openai, anthropic)"), + ) + use _, _args, flags <- glint.command() + + // Load config file (if present) and merge with CLI flags + let cfg = config.load() + let assert Ok(directions_cli) = directions(flags) + let assert Ok(model_cli) = model(flags) + let assert Ok(provider_cli) = provider(flags) + let merged = + config.merge( + cfg, + cli_provider: provider_cli, + cli_model: model_cli, + cli_directions: directions_cli, + ) + + // Read all stdin into a single string + let input = + stdin.read_lines() + |> yielder.to_list() + |> string.join("") + + // Build the user message with raw input and directions + let user_message = prompts.build_user_message(input, merged.directions) + + case providers.get_provider(merged.provider) { + Ok(provider_info) -> { + case send_request(provider_info, merged, prompts.system(), user_message) { + Ok(response) -> io.print(prompts.extract_code_block(response)) + Error(msg) -> { + io.println_error(msg) + halt(1) + } + } + } + Error(providers.FetchError(msg)) -> { + io.println_error("Error fetching providers: " <> msg) + halt(1) + } + Error(providers.ProviderNotFound(id)) -> { + io.println_error("Unknown provider: " <> id) + halt(1) + } + Error(providers.ModelNotFound(provider, model)) -> { + io.println_error( + "Unknown model '" <> model <> "' for provider '" <> provider <> "'", + ) + halt(1) + } + } +} + +fn send_request( + provider: Provider, + cfg: config.Config, + system: String, + user_prompt: String, +) -> Result(String, String) { + use api_key <- result.try(get_api_key(provider, cfg)) + + case provider.provider_type { + "openai" -> send_openai(api_key, None, cfg.model, system, user_prompt) + "anthropic" -> send_anthropic(api_key, None, cfg.model, system, user_prompt) + "google" -> send_gemini(api_key, cfg.model, system, user_prompt) + "openai-compat" -> { + case provider.api_endpoint { + Some(endpoint) -> + openai_compat.send(endpoint, api_key, cfg.model, system, user_prompt) + None -> Error("No endpoint configured for " <> provider.id) + } + } + other -> Error("Unsupported provider type: " <> other) + } +} + +fn send_openai( + api_key: String, + base_url: option.Option(String), + model: String, + system_prompt: String, + user_prompt: String, +) -> Result(String, String) { + let client = case base_url { + Some(url) -> openai.new_with_base_url(api_key, url) + None -> openai.new(api_key) + } + + starlet.chat(client, model) + |> starlet.system(system_prompt) + |> starlet.user(user_prompt) + |> starlet.send() + |> result.map(fn(resp) { starlet.text(resp.1) }) + |> result.map_error(format_starlet_error) +} + +fn send_anthropic( + api_key: String, + base_url: option.Option(String), + model: String, + system_prompt: String, + user_prompt: String, +) -> Result(String, String) { + let client = case base_url { + Some(url) -> anthropic.new_with_base_url(api_key, url) + None -> anthropic.new(api_key) + } + + starlet.chat(client, model) + |> starlet.system(system_prompt) + |> starlet.user(user_prompt) + |> starlet.send() + |> result.map(fn(resp) { starlet.text(resp.1) }) + |> result.map_error(format_starlet_error) +} + +fn send_gemini( + api_key: String, + model: String, + system_prompt: String, + user_prompt: String, +) -> Result(String, String) { + let client = gemini.new(api_key) + + starlet.chat(client, model) + |> starlet.system(system_prompt) + |> starlet.user(user_prompt) + |> starlet.send() + |> result.map(fn(resp) { starlet.text(resp.1) }) + |> result.map_error(format_starlet_error) +} + +fn format_starlet_error(err: starlet.StarletError) -> String { + case err { + starlet.Transport(msg) -> "Network error: " <> msg + starlet.Http(status, body) -> + "HTTP " <> int.to_string(status) <> ": " <> body + starlet.Decode(msg) -> "Parse error: " <> msg + starlet.Provider(name, msg, _) -> name <> " error: " <> msg + starlet.Tool(_error) -> "Tool error" + starlet.RateLimited(retry_after) -> { + case retry_after { + Some(secs) -> "Rate limited, retry after " <> int.to_string(secs) <> "s" + None -> "Rate limited" + } + } + } +} + +fn get_api_key(provider: Provider, cfg: config.Config) -> Result(String, String) { + // Precedence: api_key_cmd > api_key > environment variable + case cfg.api_key_cmd { + "" -> + case cfg.api_key { + "" -> get_api_key_from_env(provider) + key -> Ok(key) + } + cmd -> run_api_key_cmd(cmd) + } +} + +fn run_api_key_cmd(cmd: String) -> Result(String, String) { + case shellout.command(run: "sh", with: ["-c", cmd], in: ".", opt: []) { + Ok(output) -> Ok(string.trim(output)) + Error(#(_status, msg)) -> Error("api_key_cmd failed: " <> msg) + } +} + +fn get_api_key_from_env(provider: Provider) -> Result(String, String) { + case provider.api_key_env { + Some(env_ref) -> { + let env_var = resolve_env_var(env_ref) + case envoy.get(env_var) { + Ok(key) -> Ok(key) + Error(_) -> Error("Missing environment variable: " <> env_var) + } + } + None -> Error("No API key configured for provider: " <> provider.id) + } +} + +fn resolve_env_var(value: String) -> String { + case value { + "$" <> rest -> rest + other -> other + } +} diff --git a/src/openai_compat.gleam b/src/openai_compat.gleam new file mode 100644 index 0000000000000000000000000000000000000000..6a832819fba08d6f8e6c972405f6ed2bd3f2cf5e --- /dev/null +++ b/src/openai_compat.gleam @@ -0,0 +1,82 @@ +import gleam/dynamic/decode +import gleam/http +import gleam/http/request +import gleam/httpc +import gleam/json +import gleam/list + +pub fn send( + endpoint: String, + api_key: String, + model: String, + system_prompt: String, + user_prompt: String, +) -> Result(String, String) { + let messages = build_messages(system_prompt, user_prompt) + let body = + json.object([ + #("model", json.string(model)), + #("messages", json.array(messages, fn(m) { m })), + ]) + |> json.to_string + + let url = endpoint <> "/chat/completions" + + case request.to(url) { + Error(_) -> Error("Invalid endpoint URL: " <> endpoint) + Ok(req) -> { + let req = + req + |> request.set_method(http.Post) + |> request.set_header("content-type", "application/json") + |> request.set_header("authorization", "Bearer " <> api_key) + |> request.set_body(body) + + case httpc.send(req) { + Error(_) -> Error("Network error") + Ok(resp) if resp.status >= 200 && resp.status < 300 -> + parse_response(resp.body) + Ok(resp) -> Error("HTTP " <> resp.body) + } + } + } +} + +fn build_messages(system_prompt: String, user_prompt: String) -> List(json.Json) { + case system_prompt { + "" -> [user_message(user_prompt)] + sys -> [system_message(sys), user_message(user_prompt)] + } +} + +fn system_message(content: String) -> json.Json { + json.object([ + #("role", json.string("system")), + #("content", json.string(content)), + ]) +} + +fn user_message(content: String) -> json.Json { + json.object([ + #("role", json.string("user")), + #("content", json.string(content)), + ]) +} + +fn parse_response(body: String) -> Result(String, String) { + let choice_decoder = decode.at(["message", "content"], decode.string) + + let response_decoder = { + use choices <- decode.field("choices", decode.list(choice_decoder)) + decode.success(choices) + } + + case json.parse(body, response_decoder) { + Error(_) -> Error("Failed to parse response") + Ok(choices) -> + case list.first(choices) { + Ok(content) -> Ok(content) + Error(_) -> Error("No response content") + } + } +} diff --git a/src/prompts.gleam b/src/prompts.gleam new file mode 100644 index 0000000000000000000000000000000000000000..d441b474fce79c9aafe1de30039eb1b184e6f45a --- /dev/null +++ b/src/prompts.gleam @@ -0,0 +1,58 @@ +import gleam/option.{type Option, None, Some} +import gleam/string + +/// The system prompt that establishes garble's role as a text transformer. +const system_prompt = "You are a text transformation tool. Transform the text in according to the user's directions. + + +- Follow the user's transformation directions precisely +- If no directions are provided, return the input unchanged +- Output ONLY the transformed text—no explanations, commentary, or metadata +- Never reference the original text or the fact that a transformation occurred +- Wrap your entire output in a fenced code block (```) +" + +/// Returns the system prompt. +pub fn system() -> String { + system_prompt +} + +/// Build the user message with raw input sandwiched between direction references. +pub fn build_user_message(raw_input: String, directions: String) -> String { + let directions_block = case string.trim(directions) { + "" -> "" + trimmed -> "\n\n\n" <> trimmed <> "\n" + } + + "\n" + <> raw_input + <> "\n" + <> directions_block + <> "\n\nTransform the text in according to the directions above." +} + +/// Extract content from within a fenced code block. +/// Returns the content inside the first code block found, or the full text if none. +pub fn extract_code_block(text: String) -> String { + case find_code_block(text) { + Some(content) -> content + None -> text + } +} + +fn find_code_block(text: String) -> Option(String) { + case string.split_once(text, "```") { + Error(_) -> None + Ok(#(_before, after_open)) -> { + // Skip the language tag (everything until first newline) + let content_start = case string.split_once(after_open, "\n") { + Ok(#(_lang, rest)) -> rest + Error(_) -> after_open + } + case string.split_once(content_start, "```") { + Ok(#(content, _after_close)) -> Some(string.trim(content)) + Error(_) -> None + } + } + } +} diff --git a/src/providers.gleam b/src/providers.gleam new file mode 100644 index 0000000000000000000000000000000000000000..8d10661d8a199f16eed4dae0fba82200e02ce476 --- /dev/null +++ b/src/providers.gleam @@ -0,0 +1,114 @@ +import gleam/dynamic/decode +import gleam/http/request +import gleam/httpc +import gleam/int +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result + +const providers_url = "https://catwalk.charm.sh/v2/providers" + +pub type Provider { + Provider( + id: String, + provider_type: String, + api_key_env: Option(String), + api_endpoint: Option(String), + models: List(Model), + ) +} + +pub type Model { + Model(id: String) +} + +pub type ValidationError { + FetchError(String) + ProviderNotFound(String) + ModelNotFound(provider: String, model: String) +} + +pub fn validate( + provider_id: String, + model_id: String, +) -> Result(Nil, ValidationError) { + use providers <- result.try(fetch_providers()) + use provider <- result.try(find_provider(providers, provider_id)) + find_model(provider, model_id) +} + +pub fn get_provider(provider_id: String) -> Result(Provider, ValidationError) { + use providers <- result.try(fetch_providers()) + find_provider(providers, provider_id) +} + +fn fetch_providers() -> Result(List(Provider), ValidationError) { + let assert Ok(req) = request.to(providers_url) + + case httpc.send(req) { + Ok(resp) if resp.status == 200 -> parse_providers(resp.body) + Ok(resp) -> Error(FetchError("HTTP " <> int.to_string(resp.status))) + Error(_) -> Error(FetchError("Network error")) + } +} + +fn parse_providers(body: String) -> Result(List(Provider), ValidationError) { + let model_decoder = { + use id <- decode.field("id", decode.string) + decode.success(Model(id:)) + } + + let provider_decoder = { + use id <- decode.field("id", decode.string) + use provider_type <- decode.field("type", decode.string) + use api_key_env <- decode.optional_field( + "api_key", + None, + decode.string |> decode.map(Some), + ) + use api_endpoint <- decode.optional_field( + "api_endpoint", + None, + decode.string |> decode.map(Some), + ) + use models <- decode.field("models", decode.list(model_decoder)) + decode.success(Provider( + id:, + provider_type:, + api_key_env:, + api_endpoint:, + models:, + )) + } + + json.parse(body, decode.list(provider_decoder)) + |> result.map_error(fn(_) { FetchError("Invalid JSON") }) +} + +fn find_provider( + providers: List(Provider), + provider_id: String, +) -> Result(Provider, ValidationError) { + providers + |> list.find(fn(p) { p.id == provider_id }) + |> result.map_error(fn(_) { ProviderNotFound(provider_id) }) +} + +fn find_model( + provider: Provider, + model_id: String, +) -> Result(Nil, ValidationError) { + provider.models + |> list.find(fn(m) { m.id == model_id }) + |> result.map(fn(_) { Nil }) + |> result.map_error(fn(_) { ModelNotFound(provider.id, model_id) }) +} + +/// Resolve an environment variable reference like "$OPENAI_API_KEY" to just "OPENAI_API_KEY" +pub fn resolve_env_var_name(value: String) -> Option(String) { + case value { + "$" <> rest -> Some(rest) + _ -> None + } +} diff --git a/test/garble_test.gleam b/test/garble_test.gleam new file mode 100644 index 0000000000000000000000000000000000000000..fba3c8872b6dc7dd1ff3af4149a180ccd5566d09 --- /dev/null +++ b/test/garble_test.gleam @@ -0,0 +1,13 @@ +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + let name = "Joe" + let greeting = "Hello, " <> name <> "!" + + assert greeting == "Hello, Joe!" +} diff --git a/test/prompts_test.gleam b/test/prompts_test.gleam new file mode 100644 index 0000000000000000000000000000000000000000..63a954b71914e07e10a8b5622f66da7e79477e39 --- /dev/null +++ b/test/prompts_test.gleam @@ -0,0 +1,82 @@ +import gleam/string +import gleeunit/should +import prompts + +// --- system tests --- + +pub fn system_returns_prompt_test() { + let result = prompts.system() + should.be_true(result |> contains("text transformation tool")) + should.be_true(result |> contains("")) +} + +// --- build_user_message tests --- + +pub fn build_user_message_with_directions_test() { + let result = prompts.build_user_message("hello", "make it loud") + should.be_true(result |> contains("\nhello\n")) + should.be_true( + result |> contains("\nmake it loud\n"), + ) + should.be_true(result |> contains("Transform the text in ")) +} + +pub fn build_user_message_without_directions_test() { + let result = prompts.build_user_message("hello", "") + should.be_true(result |> contains("\nhello\n")) + should.be_false(result |> contains("")) + should.be_true(result |> contains("Transform the text in ")) +} + +pub fn build_user_message_trims_directions_test() { + let result = prompts.build_user_message("hello", " ") + should.be_false(result |> contains("")) +} + +// --- extract_code_block tests --- + +pub fn extract_simple_code_block_test() { + let input = "```\nhello world\n```" + prompts.extract_code_block(input) + |> should.equal("hello world") +} + +pub fn extract_code_block_with_language_test() { + let input = "```json\n{\"key\": \"value\"}\n```" + prompts.extract_code_block(input) + |> should.equal("{\"key\": \"value\"}") +} + +pub fn extract_code_block_with_surrounding_text_test() { + let input = "Here's the result:\n```\ntransformed\n```\nHope that helps!" + prompts.extract_code_block(input) + |> should.equal("transformed") +} + +pub fn extract_returns_full_text_when_no_code_block_test() { + let input = "Just plain text without any code blocks." + prompts.extract_code_block(input) + |> should.equal(input) +} + +pub fn extract_uses_first_code_block_test() { + let input = "```\nfirst\n```\n\n```\nsecond\n```" + prompts.extract_code_block(input) + |> should.equal("first") +} + +pub fn extract_handles_empty_code_block_test() { + let input = "```\n```" + prompts.extract_code_block(input) + |> should.equal("") +} + +pub fn extract_handles_unclosed_code_block_test() { + let input = "```\nunclosed content here" + prompts.extract_code_block(input) + |> should.equal(input) +} + +fn contains(haystack: String, needle: String) -> Bool { + string.contains(haystack, needle) +}