{"slug": "show-hn-esh-english-to-shell", "title": "Show HN: Esh – English to Shell", "summary": "Developer tool esh translates plain English into shell commands using a large language model, printing only the command to stdout so it composes with shell pipelines. The tool defaults to a local Ollama server at http://localhost:11434 and supports hosted providers including OpenAI, Anthropic, Google Gemini, Groq, Mistral, DeepSeek, xAI, OpenRouter, Together AI, and any OpenAI-compatible service, with a local history cache that avoids repeat model calls for identical English text. esh requires Rust 1.85 or newer, ships prebuilt binaries for macOS Apple silicon with Debian listed as coming soon, and only executes commands when the user passes --exec, which prompts for confirmation first.", "body_md": "`esh` translates plain English into shell commands using a large language model. Ask for what you want, get the command back. It works with a local [Ollama](https://ollama.com) server and with hosted providers — OpenAI, Anthropic, Google Gemini, and any OpenAI-compatible service.\n\n``` bash\n$ esh \"list all files in the current directory with their sizes\"\nls -la\n```\n\n`esh` prints **only** the command to stdout (no explanations, no markdown), so its output composes cleanly with your shell:\n\n``` bash\n$ $(esh \"show the ten largest files under this directory\")\n$ echo \"$(esh \"list all files in the current directory with their sizes\")\"\n```\n\nBy default `esh` only translates: it prints the command and leaves the decision to run it up to you. Pass `--exec` when you want `esh` to run it — it shows the command and asks for confirmation first.\n\n- **Provider-agnostic.** Use a local Ollama server, OpenAI, Anthropic, Google Gemini, or any OpenAI-compatible service — and switch with one command.\n- **Local-first.** The default provider is a local Ollama server, so nothing leaves your machine unless you choose a hosted provider.\n- **Remembers.** Repeated requests are served from a local history, so the model is not called twice for the same English text.\n- **Composable.** Only the command is printed, so it can be piped, substituted, or captured.\n\nRequires [Rust](https://www.rust-lang.org/tools/install) 1.85 or newer (the crate uses the 2024 edition).\n\n```\ngit clone <repository-url> esh\ncd esh\ncargo install --path .\n```\n\nOr build and run without installing:\n\n```\ncargo build --release\n./target/release/esh \"list files by size\"\n```\n\n- A reachable LLM provider (see [Providers](#providers) ). The default is a local Ollama server at`http://localhost:11434` .\n- An API key for hosted providers. Local Ollama and most self-hosted servers need none.\n- Network access to the provider's API host, unless you run the model locally.\n\nPrebuilt binaries are published on the [Releases page](https://github.com/numericalworks/esh/releases).\n\n| Platform | Status | \n|---|---|\n| macOS — Apple silicon (M-series) | **Available** | \n| Debian | Coming soon | \n\nDownload the latest build for your platform from [https://github.com/numericalworks/esh/releases](https://github.com/numericalworks/esh/releases). On other platforms — or to build the newest code yourself — use the [From source](#from-source) instructions above, then follow [Shell integration](#shell-integration) to wire `esh` into your shell.\n\n`esh` speaks four wire protocols. Pick a provider during setup, or change it later with [`esh setup`](#switching-providers).\n\n| Provider | `provider` id | Default base URL | API key | \n|---|---|---|---|\n| Ollama | `ollama` | `http://localhost:11434` | optional | \n| OpenAI | `openai` | `https://api.openai.com/v1` | required | \n| Anthropic (Claude) | `anthropic` | `https://api.anthropic.com/v1` | required | \n| Google Gemini | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | required | \n| Groq | `groq` | `https://api.groq.com/openai/v1` | required | \n| Mistral | `mistral` | `https://api.mistral.ai/v1` | required | \n| DeepSeek | `deepseek` | `https://api.deepseek.com/v1` | required | \n| xAI (Grok) | `xai` | `https://api.x.ai/v1` | required | \n| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | required | \n| Together AI | `together` | `https://api.together.xyz/v1` | required | \n| Custom (OpenAI-compatible) | `custom` | you supply | optional | \n\nThe OpenAI-compatible entries all speak the same protocol, so any service exposing an OpenAI-style API works — LM Studio, llama.cpp's server, vLLM, and others. Choose **Custom** and enter its base URL.\n\nThe first time you run `esh`, it walks you through setup:\n\n```\nWelcome to esh! Let's set things up.\n\nChoose your LLM provider:\n  1. Ollama  (local or self-hosted, no API key needed)\n  2. OpenAI\n  3. Anthropic (Claude)\n  ...\nProvider [1]: 1\nServer URL [http://localhost:11434]: \nAPI key (optional) [None]: \nAvailable models:\n  1. codellama\n  2. llama3:latest\nSelect a model (number or name) [1]: 2\n\nSettings saved: provider 'ollama', model 'llama3:latest'.\n```\n\n1. **Provider** — pick a number, or type a provider id. Enter selects Ollama.\n2. **Server URL** — press Enter to accept the provider's default, or type your own (required for`custom` ).\n3. **API key** — input is hidden. Hosted providers require a key; press Enter for none where the provider allows it.\n4. **Model** —`esh` lists the models the provider reports. Press Enter for the first, type a number, or type a model name directly.\n\nIf the models cannot be listed (for example the endpoint does not expose a list), `esh` asks you to type a model name. Press Enter with no model to start the wizard over.\n\nSetup only writes files once it has a provider, a URL, and a model.\n\n```\nUsage:\n  esh <english>              translate English into a shell command\n  esh --exec <english>       translate, then run the command (asks first)\n  esh --exec --yes <english> translate and run without asking\n  esh setup                  choose or change the LLM provider and model\n  esh shell-init [shell]     print shell integration for bash, zsh, or fish\n  esh history                list previously translated commands\n  esh --clear <english>      remove an entry from the history\n  esh --help                 show this help\n  esh --version              show the version\n```\n\n| Flag | Meaning | \n|---|---|\n| `-x` ,`--exec` | Run the translated command instead of only printing it | \n| `-y` ,`--yes` | Skip the confirmation prompt (requires `--exec` ) | \n| `-h` ,`--help` | Show help | \n| `-V` ,`--version` | Show the version | \n\nFlags may appear before or after the English text. Use `--` to treat everything after it as English text, so a request may begin with a dash.\n\nRun the wizard at any time to change the provider, server URL, API key, or model:\n\n``` bash\n$ esh setup\n```\n\nThis overwrites the stored configuration and credentials. No files need to be deleted.\n\n`esh --exec` runs the command in a **child process**. That is fine for ordinary commands, but commands that change your shell's own state — `cd`, `export`, `source`, `alias`, `umask` — only affect that child, so they have no lasting effect:\n\n``` bash\n$ esh -x -y \"goto home\"\ncd ~\n# ...but you are still in the same directory\n```\n\nA child process can never change its parent shell's working directory, so this cannot be fixed inside `esh` itself. The fix is a small shell function that evaluates `--exec` commands in your *current* shell. Load it from your shell's startup file:\n\n```\n# bash (~/.bashrc) or zsh (~/.zshrc)\neval \"$(esh shell-init zsh)\"    # or: esh shell-init bash\n# fish (~/.config/fish/config.fish)\nesh shell-init fish | source\n```\n\nThe snippet calls `esh` by absolute path, so it also works from a development build without installing (run this from the project root, or substitute the full path to your build):\n\n```\neval \"$(\"$PWD/target/debug/esh\" shell-init zsh)\"\n```\n\n**Important:** the integration is a shell **function** named `esh`. It only takes effect when your shell resolves the command `esh` to that function. Launching the binary directly — `cargo run -- …` or `./target/debug/esh …` — bypasses it entirely, and `cd` will not persist. Invoke `esh` itself.\n\nWith the integration loaded, `--exec` commands run in the current shell, so builtins take effect as if you had typed them:\n\n``` bash\n$ esh -x -y \"goto home\"\ncd ~\n$ pwd\n/Users/you\n```\n\nEverything else is unchanged: without `--exec` the command is still only printed, subcommands (`setup`, `history`, `shell-init`) and `--help` pass straight through, and the confirmation prompt still applies unless you pass `-y`.\n\nBecause the integration evaluates commands in your current shell, they can change your environment (and affect it more broadly than a subshell would). Keep the confirmation prompt unless you are sure.\n\nAfter loading the snippet, confirm your shell now has the function:\n\n``` bash\n$ type esh\nesh is a shell function from /Users/you/.zshrc\n```\n\nIf `type esh` prints a **path** (for example `/Users/you/.cargo/bin/esh`), the snippet is not loaded in this shell — reload your startup file (`source ~/.zshrc`) or start a new shell (` exec zsh`).\n\nNow check that a state change actually sticks. Call the function directly — not inside `$(...)` or a pipeline, which would run it in a subshell:\n\n``` bash\n$ cd /\n$ esh -x -y \"goto home\"\ncd ~\n$ pwd\n/Users/you\n```\n\nAs a control, the same command through the raw binary leaves you where you started, which confirms the function is what makes it work:\n\n``` bash\n$ cd /\n$ /Users/you/code/esh/target/debug/esh -x -y \"goto home\"\ncd ~\n$ pwd\n/\n```\n\nTo try the integration without editing your startup file, load it into the current shell first (use the absolute path to your build, or just `esh` if it is installed):\n\n``` bash\n$ eval \"$(/Users/you/code/esh/target/debug/esh shell-init zsh)\"\n$ type esh\nesh is a shell function\nbash\n$ esh \"find all *.log files modified in the last 24 hours\"\nfind . -name '*.log' -mtime -1\n```\n\nMultiple arguments are joined with spaces, so these are equivalent:\n\n``` bash\n$ esh \"list files by size\"\n$ esh list files by size\n```\n\nPass `-x`/`--exec` to run the command instead of only printing it. Because a generated command could be destructive, `esh` shows it and asks for confirmation first:\n\n``` bash\n$ esh --exec \"show the current directory in long form\"\nls -la\nRun this command? [y/N] y\ntotal 24\n...\n```\n\nPress Enter, or answer anything other than `y`/` yes`, to abort without running anything.\n\nSkip the prompt with `-y`/`--yes` (useful in scripts). `--yes` requires `--exec`:\n\n``` bash\n$ esh --exec --yes \"show the current directory in long form\"\n```\n\nIn exec mode the command and the confirmation prompt are written to **stderr**, so **stdout** carries only the executed command's output. The executed command's exit code becomes `esh`'s exit code, so it composes like any other command:\n\n``` bash\n$ esh -x -y \"run the test suite\" && echo \"tests passed\"\n```\n\nThe command runs through the system shell (`sh -c` on Unix, `cmd /C` on Windows), so pipes, redirects, and globbing behave as they would if you typed the command yourself.\n\nEvery translation is stored. If you ask for the same English text again (ignoring surrounding whitespace and letter case), the cached command is printed without contacting the model:\n\n``` bash\n$ esh \"list files by size\"\nls -laS\n\n$ esh \"  LIST files by size  \"   # served from history, no LLM call\nls -laS\n```\n\nView everything `esh` remembers:\n\n``` bash\n$ esh history\n  1. list all files in the current directory with their sizes\n     ls -la\n  2. find all *.log files modified in the last 24 hours\n     find . -name '*.log' -mtime -1\n```\n\nRemove an entry by its English text:\n\n``` bash\n$ esh --clear \"list all files in the current directory with their sizes\"\nRemoved 1 history entry.\n```\n\nIf nothing matches, `esh` says so on stderr (and exits successfully):\n\n```\nesh: no history entry found for 'list all files in the current directory with their sizes'\nphp\nflowchart TD\n    A[\"esh [--exec] <english>\"] --> B{Settings on disk?}\n    B -- no --> C[Setup wizard: provider, URL, key, model]\n    C --> D[Save config + credentials]\n    D --> E\n    B -- yes --> E[Open history store]\n    E --> F{Already translated?}\n    F -- yes --> G[Use cached command]\n    F -- no --> H[\"Call the configured provider\"]\n    H --> I[Strip whitespace and code fences]\n    I --> J[Save to history]\n    J --> G\n    G --> K{--exec?}\n    K -- no --> L[Print command to stdout]\n    K -- yes --> M[Show command, confirm, run via sh -c]\n    M --> N[Propagate the command's exit code]\n```\n\n- **Model listing** uses the provider's models endpoint (`GET /api/tags` for Ollama,`GET /models` for the others).\n- **Translation** uses the provider's generation endpoint (`POST /api/generate` for Ollama,`POST /chat/completions` for OpenAI-compatible services,`POST /messages` for Anthropic, and`POST /models/{model}:generateContent` for Gemini) with a strict prompt that instructs the model to reply with only the command. The response is trimmed and any markdown code fences or backticks are stripped before use.\n- **Auth** is provider-specific:`Authorization: Bearer <key>` for Ollama (when set) and OpenAI-compatible services,`x-api-key` plus`anthropic-version` for Anthropic, and`x-goog-api-key` for Gemini.\n- **Execution** (with`--exec` ) shows the command, optionally confirms, runs it via a child shell, and forwards its exit code. Without`--exec` , nothing is run. Commands that change shell state (`cd` ,`export` , ...) need[shell integration](#shell-integration) to affect your current shell.\n- **History matching** compares trimmed, lowercased English text.\n\n`esh` honours the XDG base directory conventions.\n\n| Purpose | Default location | Override | \n|---|---|---|\n| Provider, server URL, and model | `~/.config/esh/config.json` | `ESH_CONFIG_HOME` ,`XDG_CONFIG_HOME` | \n| API key | `~/.config/esh/credentials.json` | `ESH_CONFIG_HOME` ,`XDG_CONFIG_HOME` | \n| Translation history | `~/.local/share/esh/history.json` | `ESH_DATA_HOME` ,`XDG_DATA_HOME` | \n\nOn Windows, `%APPDATA%\\esh` and `%LOCALAPPDATA%\\esh` are used when the corresponding environment variables are present. `HOME`/` USERPROFILE` are the final fallback.\n\nThe configuration directory is created with mode `0700` and its files with mode `0600` (Unix), so they are readable only by your user.\n\n## Example configuration files\n\n`config.json` for OpenAI (a missing `provider` field defaults to `ollama`, so older configs keep working):\n\n```\n{\n  \"provider\": \"openai\",\n  \"server_url\": \"https://api.openai.com/v1\",\n  \"model\": \"gpt-4o-mini\"\n}\n```\n\n`credentials.json` (the `api_key` field is omitted when no key is set):\n\n```\n{\n  \"api_key\": \"your-api-key\"\n}\n```\n\n- The API key is stored in a separate `credentials.json` with owner-only permissions (`0600` ), and the containing directory is`0700` .\n- This is permission-based protection, **not** OS-keychain encryption. On a shared machine, anyone who can read your user's files can read the key. If you need stronger protection, use a local provider without an API key, or restrict access to your account.\n- The API key is sent only to the configured provider's base URL.\n\n```\ncargo build            # debug build\ncargo build --release  # optimised build\ncargo test             # run the test suite\ncargo clippy --all-targets\n```\n\nThe test suite covers CLI parsing (flags, subcommands, `--`, error cases), command execution and exit-code propagation, configuration round-trips (including provider defaults and validation) and file permissions, history find/add/replace/remove semantics, prompt construction and output sanitizing, and the LLM client for every protocol (model listing, auth headers, translation, empty responses, and HTTP error handling) against an in-process mock server.\n\n| Path | Contents | \n|---|---|\n| `src/main.rs` | CLI parsing, command dispatch, and command execution | \n| `src/interactive.rs` | Prompt and confirmation helpers | \n| `src/setup.rs` | Interactive configuration wizard | \n| `src/provider.rs` | Supported providers and their wire protocols | \n| `src/llm.rs` | Provider-agnostic model listing and translation | \n| `src/shellinit.rs` | Shell integration snippets (bash, zsh, fish) | \n| `src/config.rs` | Settings load/save | \n| `src/history.rs` | Translation history store | \n| `src/fsutil.rs` | Path resolution and private file writes | \n| `src/error.rs` | Error type | \n| `docs/requirements/` | Requirements documents | \n\n`esh -x \"goto home\"` prints `cd ~` but doesn't change directory`cd` is a shell builtin and `esh` runs commands in a child process, which cannot change your shell's directory (the same is true of `export`, `source`, `alias`, and `umask`). Load the [shell integration](#shell-integration) so `--exec` evaluates commands in the current shell.\n\n**`cd` still doesn't stick after loading the integration**\nMake sure you are running `esh` itself, not the binary directly. `cargo run -- -x -y …` and `./target/debug/esh -x -y …` bypass the shell function. If you run from a development checkout, point the integration at your build:\n\n```\neval \"$(\"$PWD/target/debug/esh\" shell-init zsh)\"\ntype esh    # should say: esh is a shell function\n```\n\n**\"could not reach the server\"**\nCheck that the provider is running and the server URL is correct. For local Ollama, `curl http://localhost:11434/api/tags` should return JSON.\n\n**\"the server returned HTTP 401\" / `403`**\nThe endpoint requires a valid API key. Run `esh setup` and enter a key for the selected provider.\n\n**\"Anthropic requires an API key\" / \"Google Gemini requires an API key\"**\nThese providers cannot be used without a key. Re-run `esh setup` and provide one.\n\n**\"unknown provider ... in the configuration\"**\nThe config references a provider id `esh` does not know. Run `esh setup` to pick a valid provider.\n\n**The model returns a bad or multi-line answer**\n`esh` uses a strict prompt and strips code fences, but a small or poorly suited model can still produce extra text. Prefer a capable code-oriented model in setup.\n\n**Setup keeps failing**\nVerify the server URL and that the provider exposes at least one model. If it cannot list models, type a model name directly when prompted. You can also set things up non-interactively by writing the config files described above.\n\n- Execution is opt-in (`--exec` ) and never happens in the default, print-only mode.\n- `--exec` runs commands in a child shell, so shell-state changes (`cd` ,`export` , ...) do not persist unless the[shell integration](#shell-integration) is loaded.\n- `esh` does not attempt to judge whether a generated command is safe; with`--exec` it relies on your confirmation, and with`--exec --yes` it runs the command as-is.\n- The provider is chosen globally, not per request; switching providers means re-running `esh setup` .\n- Model listing shows whatever the provider returns, including non-chat models for some providers.\n- History entries are matched by exact English text (case/whitespace-insensitive); there is no fuzzy matching.\n- The API key is protected by file permissions rather than an OS keychain.\n\nGPL-V3.0 or later.", "url": "https://wpnews.pro/news/show-hn-esh-english-to-shell", "canonical_source": "https://github.com/numericalworks/esh", "published_at": "2026-09-27 03:56:48+00:00", "updated_at": "2026-09-27 04:31:03.654945+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "large-language-models", "ai-products"], "entities": ["esh", "Ollama", "OpenAI", "Anthropic", "Google Gemini", "Groq", "Mistral", "Rust"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/show-hn-esh-english-to-shell", "markdown": "https://wpnews.pro/news/show-hn-esh-english-to-shell.md", "text": "https://wpnews.pro/news/show-hn-esh-english-to-shell.txt", "jsonld": "https://wpnews.pro/news/show-hn-esh-english-to-shell.jsonld"}}