<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Assaf Schwartz</title>
    <description>The latest articles on DEV Community by Assaf Schwartz (@tfcace).</description>
    <link>https://dev.to/tfcace</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4057001%2F921bf456-bc64-4c17-8179-44cbffd000df.png</url>
      <title>DEV Community: Assaf Schwartz</title>
      <link>https://dev.to/tfcace</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tfcace"/>
    <language>en</language>
    <item>
      <title>A shell that writes its own tab completions</title>
      <dc:creator>Assaf Schwartz</dc:creator>
      <pubDate>Fri, 31 Jul 2026 17:32:12 +0000</pubDate>
      <link>https://dev.to/tfcace/a-shell-that-writes-its-own-tab-completions-423f</link>
      <guid>https://dev.to/tfcace/a-shell-that-writes-its-own-tab-completions-423f</guid>
      <description>&lt;p&gt;Type this into most shells and watch what happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❯ docker rm &amp;lt;TAB&amp;gt;
Dockerfile        docker-compose.yml    docs/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filenames... &lt;code&gt;docker rm&lt;/code&gt; does not take a filename. It takes the name or ID of a container that exists on this machine right now, and the machine knows exactly which ones those are. The shell just has no idea how to ask...&lt;/p&gt;

&lt;p&gt;You can fix this yourself, in most shells: a bash function juggling &lt;code&gt;COMP_WORDS&lt;/code&gt; and &lt;code&gt;COMPREPLY&lt;/code&gt;, a zsh &lt;code&gt;#compdef&lt;/code&gt; file in the &lt;code&gt;_arguments&lt;/code&gt; DSL, or, closest to easy, a one-line fish &lt;code&gt;complete&lt;/code&gt; command. &lt;a href="https://github.com/scop/bash-completion" rel="noopener noreferrer"&gt;bash-completion&lt;/a&gt; is tens of thousands of lines of that fix. At that price, I wouldn't write a completion for an internal CLI at 4pm on a Tuesday.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://github.com/tfcace/hash" rel="noopener noreferrer"&gt;Hash&lt;/a&gt;, I wanted this to stay easy and hackable: you should be able to teach your shell a new tool yourself, in a minute, without shell scripting and without waiting for me or the tool's author to do it for you. That seemed like the right thing to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thirty seconds, one command
&lt;/h2&gt;

&lt;p&gt;Here is that minute. A real run of &lt;code&gt;completions generate&lt;/code&gt; against &lt;code&gt;gh&lt;/code&gt;, trimmed; the full output came back with ten rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❯ completions generate gh
Inspecting `gh --help`...
Asking the agent to draft a completion plugin for gh...

Generated plugin for gh:

  [plugin]
  name = "gh"
  description = "Complete GitHub resource names for the gh CLI"
  commands = ["gh"]

  [[rules]]
  subcommands = ["pr view", "pr checkout", "pr diff", "pr merge", "pr edit", ...]
  max_args = 1
  [rules.source]
  exec = ["gh", "pr", "list", "--state", "all", "--limit", "100",
          "--json", "number,title", "--jq", ".[] | \"\(.number)\t\(.title)\""]
  delimiter = "\t"
  value_column = 1
  description_column = 2
  timeout = "8s"
  cache_ttl = "30s"

  [[rules]]
  subcommands = ["run view", "run watch", "run rerun", "run cancel", ...]
  max_args = 1
  [rules.source]
  exec = ["gh", "run", "list", "--limit", "100",
          "--json", "databaseId,displayTitle", "--jq", ".[] | \"\(.databaseId)\t\(.displayTitle)\""]
  ...

10 rules covering: pr view, pr checkout, pr diff, ..., issue view, issue close,
repo view, repo clone, release view, run view, workflow run, label edit,
secret delete, variable delete, ...

[a]ccept  [r]evise &amp;lt;what to change&amp;gt;  [q]uit:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt at the bottom is the feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[a]ccept&lt;/code&gt; when the draft is good enough. Often it is.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[r]evise also cover gists&lt;/code&gt; sends your instruction plus the current spec back to the agent. As many rounds as you like.&lt;/li&gt;
&lt;li&gt;A draft that fails validation just hides &lt;code&gt;accept&lt;/code&gt; and sends the error along with your next instruction. One round usually fixes it.&lt;/li&gt;
&lt;li&gt;Unknown fields are hard errors, so a machine-written &lt;code&gt;cache_tt&lt;/code&gt; fails loudly while the model is still around to fix it, instead of shipping a plugin that looks correct and behaves wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Accepting writes the file to &lt;code&gt;~/.config/hash/completions/&lt;/code&gt;, hot reloads the plugin table, and makes the completion live in the same shell. No restart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❯ completions list
Completion plugins:
  docker         docker (built-in, 13 rules)
  gh             gh (user, 10 rules)

User plugins live in ~/.config/hash/completions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From this moment, &lt;code&gt;gh pr checkout &amp;lt;TAB&amp;gt;&lt;/code&gt; completes PR numbers with their titles beside them, and &lt;code&gt;gh run watch &amp;lt;TAB&amp;gt;&lt;/code&gt; knows your workflow runs.&lt;/p&gt;

&lt;p&gt;Edit the file by hand and run &lt;code&gt;completions reload&lt;/code&gt;. Regenerate it. Delete it. It's yours from the moment you accept it. That's my gift to you: the agent gets you past the blank page, and then leaves.&lt;/p&gt;

&lt;p&gt;That is the whole feature. The rest of this post is what just happened behind the scenes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who can tell the shell which containers exist?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgoldenhex.dev%2Fimages%2Fposts%2Fcompletion-ladder.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgoldenhex.dev%2Fimages%2Fposts%2Fcompletion-ladder.svg" alt="diagram" width="880" height="530"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Hash already had the first three: tool-native completion for CLIs that ship it (kubectl, gh), hand-written handlers for a deliberate short list, and the ?? agent. Plugins fill the fourth row.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first three all wait on somebody. Tool-native completion is excellent where it exists, but the tool's author has to have shipped it. Built-in handlers exist because &lt;em&gt;I&lt;/em&gt; shipped them, which does not scale beyond my motivation and bandwidth.&lt;/p&gt;

&lt;p&gt;The missing tier is for the very large category of tools that have live, queryable state and no completion protocol. Something has to run &lt;code&gt;docker ps&lt;/code&gt;. The only question is who declares that, and in what language.&lt;/p&gt;
&lt;h2&gt;
  
  
  The format is deliberately not a language
&lt;/h2&gt;

&lt;p&gt;The file the agent wrote answers three questions: where it applies, which command produces candidates, and which column contains the value. That reflects the design question, which was never "what can a plugin do?" but "how little can a plugin be allowed to do and still cover the &lt;code&gt;docker rm&lt;/code&gt; case?" The result is small enough to skip the agent entirely and write by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# ~/.config/hash/completions/kubectl.toml
[plugin]
name = "kubectl"
commands = ["kubectl", "k"]

[[rules]]
subcommands = ["delete pod", "describe pod", "logs"]
[rules.source]
exec = ["kubectl", "get", "pods", "-o", "custom-columns=NAME:.metadata.name,STATUS:.status.phase", "--no-headers"]
value_column = 1
description_column = 2
cache_ttl = "5s"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole thing. The &lt;code&gt;exec&lt;/code&gt; array is passed directly as argv with no shell interpretation, each output line becomes one candidate, and Hash splits and maps the columns:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgoldenhex.dev%2Fimages%2Fposts%2Fcompletion-spec-anatomy.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgoldenhex.dev%2Fimages%2Fposts%2Fcompletion-spec-anatomy.svg" alt="diagram" width="880" height="486"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Shown with the docker plugin's &lt;code&gt;docker ps&lt;/code&gt; source; the kubectl spec above maps the same way. The parser is one function: split the line on the delimiter, take one column as the value and optionally another as the description, drop empties and duplicates. There is very little surface area for bugs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The interesting part of that format is what it refuses to have. There are no conditionals, no variables, no access to the current word, no way to post-process the source's output. When output needs reshaping, the reshaping goes inside the source command itself. &lt;code&gt;terraform workspace list&lt;/code&gt; marks the current workspace with an asterisk, so a workspace plugin strips it there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[rules.source]
exec = ["sh", "-c", "terraform workspace list | tr -d '* '"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline lives in the plugin's own command, rather than in a config language slowly growing into one.&lt;/p&gt;

&lt;p&gt;Two constraints do most of the work of keeping it tight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A matched rule owns the argument.&lt;/strong&gt; When a rule matches and the source answers with nothing, that's an answer: "no matches." Completion stops there and does not helpfully offer you filenames instead. &lt;code&gt;docker rm&lt;/code&gt; with no containers running should give you nothing, because nothing is the truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugins never complete flags.&lt;/strong&gt; Type &lt;code&gt;-&lt;/code&gt; and the plugin declines and lets a lower tier answer. This one is a scope decision rather than a technical limit: Cobra already completes flags properly for the tools that support it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The built-in plugin is a plugin
&lt;/h3&gt;

&lt;p&gt;The fastest way to end up with a bad extension format is to ship the "real" implementation in your own language and the extension surface as a lesser thing beside it. The internal path stays expressive, the public one calcifies, and you never feel the difference because you never use it.&lt;/p&gt;

&lt;p&gt;So the only third-party completion plugin Hash ships is for Docker, and it is a TOML file. Not a TOML-shaped API. Actual TOML, as a string constant in the binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const builtinDockerSpec = `
[plugin]
name = "docker"
description = "Containers, images, volumes, and networks for docker"
commands = ["docker"]
value_flags = ["-c", "--context", "-H", "--host", ...]

[[rules]]
subcommands = ["rm", "inspect", "logs", "container rm", "container inspect", ...]
forward_flags = ["-c", "--context"]
[rules.source]
exec = ["docker", "ps", "-a", "--format", "..."]
delimiter = "\t"
value_column = 1
description_column = 2
timeout = "3s"
cache_ttl = "0s"
...
`

func builtinPluginSpecs() []*PluginSpec {
    return []*PluginSpec{
        mustParsePluginSpec(builtinDockerSpec),
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;mustParsePluginSpec&lt;/code&gt; is the whole point. It runs the same &lt;code&gt;ParsePluginSpec&lt;/code&gt; your files go through, including the strict unknown-field check, and it &lt;em&gt;panics&lt;/em&gt; on failure. A typo in the built-in spec is a shell that refuses to start, which is exactly the deal I want: if I ever weaken the format, or add a key the parser doesn't accept, I find out immediately rather than shipping an extension surface I never actually use.&lt;/p&gt;

&lt;p&gt;It buys real things. Writing those &lt;strong&gt;13&lt;/strong&gt; rules kept hitting cases the format could not express, and with no internal escape hatch available, every one of them had to become a format feature that your plugins get too.&lt;/p&gt;

&lt;p&gt;And because it is genuinely just a plugin, it has no privileges. Write a &lt;code&gt;docker.toml&lt;/code&gt; of your own and yours replaces mine wholesale. Ship one with &lt;code&gt;disabled = true&lt;/code&gt; and mine is gone with nothing in its place. The built-in is the reference implementation in the most literal sense: you can read all of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the agent gets it right
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;completions generate&lt;/code&gt; was not the plan; it showed up later, in the shower, where my best ideas always come. It works because the format is small. TOML-with-no-logic is auditable: you can read a plugin in five seconds and know exactly what will run on your machine. The same property means &lt;strong&gt;a language model can draft one reliably&lt;/strong&gt;, because there is so little program surface to get subtly wrong: a fixed set of keys, a fixed shape, and a validator.&lt;/p&gt;

&lt;p&gt;Mechanically, the shell captures the tool's own &lt;code&gt;--help&lt;/code&gt;, hands the agent the spec reference plus that help text, and gets back TOML. Everything else in the &lt;code&gt;gh&lt;/code&gt; run was inferred. Every source asks for &lt;code&gt;--json&lt;/code&gt; plus a &lt;code&gt;--jq&lt;/code&gt; template that emits exactly two tab-separated columns, because the prompt prefers machine-readable output over scraping human tables and the model mapped that rule to &lt;code&gt;gh&lt;/code&gt;'s &lt;code&gt;--jq&lt;/code&gt; support. &lt;code&gt;max_args = 1&lt;/code&gt; landed on every rule because &lt;code&gt;gh pr checkout 4821 extra-arg&lt;/code&gt; stops being a PR reference after the first positional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part was latency
&lt;/h2&gt;

&lt;p&gt;The format took an afternoon. The rest of the time went into one mismatch: &lt;strong&gt;a completion source is a process, and TAB is a keystroke.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The completion router gets about &lt;strong&gt;150 ms&lt;/strong&gt; to show &lt;em&gt;something&lt;/em&gt;. A cold, busy, or remote &lt;code&gt;docker ps&lt;/code&gt; can take longer than that, and both obvious answers are bad: block on it and TAB feels broken; give up at the deadline and the source never finishes, so the plugin never works at all.&lt;/p&gt;

&lt;p&gt;The way out is to stop treating the source as part of the keystroke:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgoldenhex.dev%2Fimages%2Fposts%2Fcompletion-cold-tab.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgoldenhex.dev%2Fimages%2Fposts%2Fcompletion-cold-tab.svg" alt="diagram" width="880" height="700"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The source is launched on a detached context, bounded only by its own timeout, so a UI deadline can never kill it before it fills the cache. The completion that asked gives up after 40 ms; the source does not.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Walk through it from top to bottom. Each piece exists because the naive version failed in a specific way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait 40 ms, then get out of the way.&lt;/strong&gt; A cold source gets &lt;strong&gt;40 ms&lt;/strong&gt; of the shared &lt;strong&gt;150 ms&lt;/strong&gt; budget; past that, Hash shows a dim &lt;code&gt;fetching completions...&lt;/code&gt; and lets the detached process run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conceding is not failing.&lt;/strong&gt; Giving up waiting returns a distinct &lt;code&gt;Pending&lt;/code&gt; result: this argument is mine, the data is in flight, nobody else answers for it. Without that, a slow &lt;code&gt;docker ps&lt;/code&gt; degrades into filenames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The menu fills itself in.&lt;/strong&gt; When the source lands, a callback re-runs the completion and opens the menu. No second TAB, and that detail is the difference between a thing you use and a thing you remember exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale beats empty.&lt;/strong&gt; Past &lt;code&gt;cache_ttl&lt;/code&gt;, the old output is still served for up to a minute while a refresh runs in the background. So &lt;code&gt;cache_ttl&lt;/code&gt; controls how &lt;em&gt;fresh&lt;/em&gt; the data is, not whether TAB feels responsive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure has to be remembered.&lt;/strong&gt; This was the good bug: with Docker down, the failure callback woke the editor, which retried, which relaunched the failing source, forever. A negative cache remembers a failure for &lt;strong&gt;5 s&lt;/strong&gt; and breaks the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cache key includes the working directory.&lt;/strong&gt; &lt;code&gt;gh pr list&lt;/code&gt; answers differently in every repository; after a &lt;code&gt;cd&lt;/code&gt;, the wrong project's answers are worse than none.&lt;/p&gt;

&lt;p&gt;The one place this design fights itself is freshness: run &lt;code&gt;docker stop web-server&lt;/code&gt;, then &lt;code&gt;docker rm &amp;lt;TAB&amp;gt;&lt;/code&gt;, and a cache doing its job shows the state from before your own command. That is what &lt;code&gt;cache_ttl = "0s"&lt;/code&gt; is for. Caching is right by default and wrong precisely where you are the one mutating the state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it won't do
&lt;/h2&gt;

&lt;p&gt;The limits matter. A feature described only by its happy path is marketing.&lt;/p&gt;

&lt;p&gt;The quality of a generated spec is only as good as the tool's &lt;code&gt;--help&lt;/code&gt;. Tools with poor help produce thin plugins, and the agent is instructed not to invent subcommands it cannot see, so the failure mode is a plugin that covers less than you hoped rather than one that invents commands. Sources must be read-only, non-interactive, and bounded: Hash may run them during completion, in their own process group, with stdin from &lt;code&gt;/dev/null&lt;/code&gt;. A failed source stays silent and falls through to the next tier, so a broken plugin looks like no plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concluding
&lt;/h2&gt;

&lt;p&gt;The feature is a TOML file and a subprocess. The idea is that extensibility only counts if the cost of extending is lower than the cost of tolerating the gap.&lt;/p&gt;

&lt;p&gt;For thirty years, teaching a shell about your tools has meant writing shell scripts, and nearly every developer has decided that tolerating filenames where container names should be is cheaper. Dropping the price to twelve lines of TOML changes that math. Dropping it to one command, with a model doing the typing and a validator holding the line, changes it again.&lt;/p&gt;

&lt;p&gt;Completion plugins land in the next Hash release. The docs are &lt;a href="https://github.com/tfcace/hash/blob/master/docs/completion-plugins.md" rel="noopener noreferrer"&gt;here&lt;/a&gt;, the built-in &lt;code&gt;docker&lt;/code&gt; spec is the reference implementation, and if &lt;code&gt;completions generate&lt;/code&gt; writes something wrong for a tool you use, that's a bug I want to see.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://goldenhex.dev/2026/07/a-shell-that-writes-its-own-tab-completions/" rel="noopener noreferrer"&gt;goldenhex.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>cli</category>
      <category>ai</category>
      <category>terminal</category>
    </item>
  </channel>
</rss>
