I replaced the AWS CLI completer with a datalake
I needed to tell someone in Italy my availability in their timezone, typed TZ= and hit tab, and discovered a completer that’s apparently been sitting in zsh since the Pleistocene. That made me finally look at how completion actually works: #compdef, the dispatch table, _files, the whole vocabulary kit I’d been leaning on for years without really seeing. And in the middle of that I remembered the thing that had made me write off tab completion in the first place: aws_completer, the Python-spawning hog that claims every argument position and still makes a mockery of my left pinky finger when it innocently asks for a filename, interrupting to say: but wait, are you sure you don’t want to marry one of my 428 eligible daughters first?
I replaced it. _aws_move_outtamyway covers 428 AWS services, 18,115 operations, 341 waiters, and 46 regions. It returns in milliseconds because there is nothing to compute at tab time; the vocabulary is pre-baked into a static shell function generated from a DuckDB catalog of the AWS API contract.
Botocore already has the words
The AWS CLI’s command vocabulary comes from botocore, AWS’s Python SDK data layer. Every AWS service has a directory under botocore/data/ containing a service-2.json file with the full API model — every operation name, every parameter, every enum value. The stock completer accesses this by loading the entire CLI framework into a Python process on every tab press. I access it by cloning the repo once, parsing the JSON, and writing parquet.
The ingest script walks botocore’s data directory, extracts service names and operation names (stored as PascalCase — DescribeInstances, PutObject), and writes partitioned parquet files. Waiters come from waiters-2.json. Regions come from endpoints.json. The output is a date-partitioned datalake:
datalake/aws_api_catalog/
dam_effective_date=2026-04-02/
operations.parquet
waiters.parquet
regions.parquet
DuckDB reads the parquet directly. No database server, no ETL step. The parquet is the catalog.
PascalCase all the way down
Botocore stores operation names in PascalCase. The CLI presents them in kebab-case. DescribeInstances becomes describe-instances. PutBucketAcl becomes put-bucket-acl. A simple regex won’t handle it — AWS’s acronym casing is inconsistent across 18,000 operations, and I did not want to reimplement their conversion logic.
A DuckDB community extension called inflector handles it:
INSTALL inflector FROM community;
LOAD inflector;
SELECT service_name, inflector_to_kebab_case(operation_name) as cli_command
FROM read_parquet('datalake/aws_api_catalog/dam_effective_date=*/operations.parquet',
hive_partitioning=true);
The inflector handles all 18,115 operations in one pass. The original PascalCase stays in the parquet — the transformation happens at read time, preserving the raw botocore contract.
s3 is porcelain
While mapping CLI service names to botocore directories, I found nine CLI commands that have no botocore directory at all. The most interesting: s3api.
I had always assumed s3 and s3api were two different APIs to the same storage system — one high-level, one low-level. That’s how the CLI presents them. But botocore has exactly one s3 directory. There is no s3api directory. There is no second service model.
aws s3api is the CLI namespace that exposes botocore’s s3 operations directly — 102 raw API verbs like put-object, list-buckets, get-bucket-acl. aws s3 is porcelain — nine hand-written commands (cp, sync, ls, mv, mb, rb, rm, presign, website) that wrap the underlying API calls with retry logic, multipart upload handling, and progress bars.
The two namespaces were always one service model with a friendlier doorman. The completer had to know the difference. Botocore operations go under s3api. The custom commands go under s3. A maintained registry file maps the handful of CLI-only commands across all services:
{
"_aliases": {
"s3api": "s3",
"configservice": "config",
"ddb": "dynamodb"
},
"s3": ["cp", "ls", "mb", "mv", "presign", "rb", "rm", "sync", "website"],
"configure": ["set", "get", "list", "import", "export-credentials"]
}
Three aliases, a few custom command lists. That’s the entire delta between botocore’s API contract and the CLI’s user-facing vocabulary.
The classified ones are in endpoints.json
Botocore’s endpoints.json lists every AWS region grouped by partition. Most people know three: aws (commercial), aws-cn (China), aws-us-gov (GovCloud). The file lists seven. The other four are classified:
| Partition | What |
|---|---|
aws-iso | C2S — Top Secret (IC/DoD) |
aws-iso-b | SC2S — Secret |
aws-iso-e | European sovereign |
aws-iso-f | Australian sovereign |
Each has its own ARN prefix, its own DNS suffix, its own IAM boundary. The completer surfaces all 46 regions across all seven partitions, including us-isof-south-1. Nobody outside those facilities can use it, but the botocore contract says it exists, and the catalog doesn’t lie.
What the tab key actually runs
The generator queries the catalog and emits a static shell function — one for zsh, one for bash. The zsh version uses two associative arrays (_aws_momy_ops mapping services to operations, _aws_momy_waiters mapping services to waiter names), an _arguments -C dispatch with global options, and a case statement for positional completion. --profile reads ~/.aws/config at tab time via sed — the only file read the completer does. Everything else is baked in.
The _files fallback that the stock completer never provides is trivial here. After service and subcommand are resolved, the argument state offers file completion. aws s3 cp ./my[tab] completes local filenames. The stock completer blocks this to show you 867 service names you’re not asking for.
428 services, zero latency
428 services. 18,115 operations. 341 waiters. 46 regions. 445 KB for the zsh version, 457 KB for bash. Zero network calls, zero subprocess spawning, zero Python at tab time. make generate produces the whole thing in under 3 seconds from a catalog query.
The generated files are committed to the repo. Anyone can make install without running the ingest pipeline. But if AWS adds services — they add about 20 per year — regenerating is one command:
make ingest && make generate && make install
The ingest writes a new date partition each time, so the datalake accumulates a history of the API contract. What changed between March and April:
SELECT a.service_name, inflector_to_kebab_case(a.operation_name)
FROM read_parquet('dam_effective_date=2026-04-01/operations.parquet') a
LEFT JOIN read_parquet('dam_effective_date=2026-03-01/operations.parquet') b
ON a.service_name = b.service_name AND a.operation_name = b.operation_name
WHERE b.operation_name IS NULL;
The completer is the first consumer of this catalog. It won’t be the last.
The same pattern at a different scale
The _time_zone completer that started this reads /usr/share/zoneinfo/ — a directory tree that’s already on the filesystem, turned into a navigable vocabulary by four lines of zsh. The AWS completer does the same thing at a different scale: botocore’s service models, already on GitHub, turned into a navigable vocabulary by a parquet catalog and a DuckDB query.
Both are the same pattern. The tab key is sacred. It should return in milliseconds. It should know when to get out of the way.
The repo is at github.com/fizz/_aws_move_outtamyway. MIT license. One curl to install.