Why I turned ChatGPT into a Unix command
When GPT-3.5 became useful for reshaping code and structured text, a browser chat no longer felt like the right interface. I built Q, a small streaming wrapper that still lives in my dotfiles.
- Published
- Updated
- Reading time
- 11 min read
- Series
- Dotfiles
I subscribed to ChatGPT not long after it became available, and I still remember realizing that GPT-3.5 could write code. The code was rarely great and sometimes impressively wrong, but it could assemble a plausible function and work with programming syntax well enough to be useful.
What interested me even more was its ability to reshape structured text. I could give it a directory listing and ask for a YAML document, hand it a Gettext catalog and ask it to draft the missing translations, or provide a set of Terraform declarations and ask it to extract candidate variable definitions. These were tasks I understood well enough to describe and verify, but which were often tedious to do by hand.
At that point the browser started to feel like the wrong interface. I work primarily in Vim and spend much of the day in a terminal. Source code, configuration, diffs, logs, SQL, JSON, YAML, and command output are already text streams there. Copying one of those streams into a chat window, waiting for a response, and copying it back broke an otherwise simple workflow.
I wanted to send text to the model in the same way I sent text to any other command. That led to Q, a small shell wrapper around the OpenAI API. It accepted a prompt or standard input and streamed the response back to standard output. The name was short enough that using it never felt like switching into a separate AI application, and the command has remained in my dotfiles ever since.
The useful surprise was transformation
Code generation attracted most of the attention around ChatGPT, but transformation was where I first found dependable everyday uses for it.
A regular expression is ideal when the input and output rules are exact. If I need the same operation repeatedly, I would rather write a small script and test it. There is another class of task, however, where the input is irregular, the desired result is easy to show, and the work may only happen once. Writing a parser for that can take longer than producing and reviewing a model-generated draft.
A directory listing is a simple example. I might want a rough service inventory from a tree that grew organically:
{
printf '%s\n' 'Convert this directory tree into a YAML service inventory.'
tree services
} | Q -
For a translation catalog, I could combine an instruction with the file and write the result somewhere temporary:
{
printf '%s\n' 'Translate empty msgstr values into German. Preserve PO syntax.'
cat locale/messages.po
} | Q - > /tmp/messages.de.po
msgfmt --check /tmp/messages.de.po
Terraform declarations worked in much the same way:
{
printf '%s\n' 'Extract reusable inputs as Terraform variable declarations.'
cat main.tf
} | Q - > /tmp/variables.tf
None of these examples ends when Q finishes. I still parse or format the output, compare record counts, inspect the diff, and read what changed. The model saves the mechanical first pass; the existing tools around the file tell me whether the result is structurally acceptable.
That combination made the early models much more useful than their reliability alone would suggest. They did not have to be right without supervision. They had to save enough work that reviewing their answer was cheaper than creating it manually.
A small interface suited the terminal
The first version of Q was a short Bash script built from tools I already used. It accepted command-line arguments, read standard input when called with -, and opened a small interactive prompt when neither was present. curl sent the request, while jq built the JSON payload and extracted text from the response.
Its interface was deliberately ordinary:
arguments or stdin → Q → stdout
Because the answer went to standard output, I could read it directly, redirect it to a temporary file, or pass it into another command. The wrapper did not need to know whether its input was a diff, a configuration file, an error message, or a half-remembered question about ffmpeg.
Over time, quick questions became at least as common as transformations. I use a lot of command-line tools whose more obscure flags refuse to stay in my memory. Asking Q how to scale a video without losing its audio, what an unfamiliar error means, or which variation of find I need is often faster than leaving the terminal to search for the same fragment of documentation again.
Some questions are funny mainly because of how specific terminal problems become: turn this wall of output into a table; explain why this regular expression matches everything except the thing I need; reconstruct the ffmpeg command that does not rotate a portrait video into abstract art. A one-line command is a good interface for that kind of interruption because it does not ask me to create a conversation around it.
I do not pipe the resulting shell commands directly into sh. I read them first. There is a large difference between asking for a command and granting probabilistic output authority over the machine, even if Unix makes both equally easy to type.
Streaming made the wrapper pleasant to use
A wrapper like this could wait for the complete response before printing anything, but that feels unnecessarily slow in an interactive terminal. Q requested streamed responses from its first version. curl --no-buffer exposed events as they arrived, and a small sed and jq --unbuffered pipeline removed the event framing and printed each text delta.
The immediate feedback changed the feel of the tool. Text started appearing shortly after I pressed Return, in the same terminal where the prompt originated. There was no separate window holding the canonical copy of the answer and no pause followed by a large block of text.
Streaming also made a bad response easier to notice. If a request for YAML began with an essay about YAML, I could stop it without waiting for the model to finish the introduction. Most of the time, though, watching a useful transformation appear line by line was simply satisfying.
I liked that the implementation remained mundane. Bash, curl, jq, and sed were enough to turn a remote model into something that behaved like a local text command. Later versions added a little grep to select the relevant response events, but the basic approach stayed the same.
The implementation followed the APIs, while the command stayed familiar
The history of Q reflects how quickly the model and API landscape changed. Its first version used the Chat Completions API and selected GPT-3.5 directly in the script. I soon moved the model name into an environment variable, then did the same for the API base URL. That let me update defaults or override them for one invocation without changing callers.
The default model moved through GPT-4 previews, GPT-4 Turbo, GPT-4o, and later GPT-5 versions. Those changes improved what the command could do, but they did not require a new way to use it.
Moving from Chat Completions to the Responses API was a larger internal change. The message array became separate instructions and input, token settings changed, and streamed output arrived through different event types. I updated the request and response pipeline and explicitly disabled response storage. From the shell, something | Q - continued to work as before.
When reasoning effort became configurable, I exposed it as another optional environment variable. More recently, I changed input handling so piped content flows into the jq process that constructs the request instead of first being captured in a shell variable. That handles multiline input more cleanly and better matches the pipeline-oriented interface.
The wrapper has lasted because it keeps those implementation changes behind a command shaped around my workflow. The useful abstraction was never a particular model or endpoint. It was being able to send text and receive text without leaving the terminal.
The Ruby version clicked as it streamed
Alongside the shell script, I experimented with a Ruby implementation called Qruby. It used HTTPX for streaming, JSON for parsing, and Ruby2D for one feature the Bash version conspicuously lacked: sound effects.
I loaded a short click sample and played it whenever a response chunk was printed. A concise answer produced a pleasant mechanical rhythm. A verbose one sounded increasingly like a Geiger counter attached to a documentation generator. It was unnecessary and oddly satisfying.
The experiment was not only about the sound. The Ruby version grew a reusable query function, configurable model and system instructions, optional conversation history, better input detection, and more careful handling of event fragments. Those pieces were easier to express as Ruby methods than as additions to a shell pipeline.
Qruby also required HTTPX, Ruby2D, a suitable Ruby interpreter, and a click file from my music directory. The Bash version depended on tools that were already part of my environment, so it remained the command I actually kept using. I still like that both versions live in the history: one became infrastructure, while the other records the period when making streamed model output click seemed like a perfectly reasonable evening project.
Plausible structure still needs scrutiny
Using the models early made their strengths obvious, but it also exposed their weaknesses quickly. They were very good at producing output that looked right, including output that was wrong in ways a parser would not catch.
A YAML document can be valid while omitting an input record. A .po file can pass msgfmt --check while using the wrong technical term. Terraform can format successfully while changing a default or dropping an important validation. A suggested shell command can use a familiar-looking flag with different behavior on my operating system.
I therefore use Q for work with clear review points. Depending on the task, that means preserving and counting identifiers, generating into a temporary file, parsing the result with the real parser, running formatters and validators, inspecting a diff, or executing tests. I also keep the request narrow enough that I can understand the answer.
Syntax checks are only one layer. A person still has to review the meaning of a translation, and compiling generated code does not establish that it behaves correctly. When a command matters, the manual remains more authoritative than a fluent recollection of it.
There is also a data boundary. I do not send secrets, credentials, private client material, .env files, or Terraform state to an external model API. A convenient pipe makes it especially important to remember where the bytes are going.
Q does not enforce all of this for me. Its limited role helps: it returns text and stops. I decide what becomes a file, a command, or a committed change.
A tool from before agents that still has a place
Coding agents later became a much larger part of my workflow. They can inspect repositories, edit files, run tests, react to failures, and maintain context across a task. Q does none of those things. It receives one request and returns one response without knowing whether the result worked.
That limitation is also why it remains useful. Not every question needs a repository, a tool loop, or delegated implementation. Sometimes I have text in the terminal and want help understanding or reshaping it. For that job, a small pipe is still faster and clearer than starting a larger workflow.
My early excitement about language models was always mixed with doubt about their output. Q found a comfortable place between those reactions. It gave me immediate access to the useful part—working with language and structure—while leaving execution, validation, and judgment in the tools and habits I already had.
I still invoke it from time to time. The models and APIs behind it have changed repeatedly, but the reason for keeping it has not: it fits the way I work with text.