lexdrill

The lexdrill gem installs a drill command that prints a vocabulary word or phrase on demand, tracking how often each one has been shown.

lexdrill demo

Installation

gem install lexdrill

drill: command not found after install?

This happens when the gem's executable directory isn't on your PATH — common with a plain system Ruby (no rbenv/rvm). Fix it in one step:

bash

echo "export PATH=\"$(ruby -e 'puts Gem.bindir'):\$PATH\"" >> ~/.bashrc
source ~/.bashrc

zsh

echo "export PATH=\"$(ruby -e 'puts Gem.bindir'):\$PATH\"" >> ~/.zshrc
source ~/.zshrc

Using an rbenv-managed Ruby? Run rbenv rehash instead. Using rvm? You shouldn't hit this — rvm keeps gem executable directories on PATH automatically.

Usage

All of lexdrill's state lives in one place: ~/.drill/ (mode 0700). It's a single global config — no per-project lists, no environment variable overrides. Create ~/.drill/words yourself — one word or phrase per line:

apple
banana
cherry

If you don't create one, drill next seeds ~/.drill/words with a default starter list (a set of NLP presuppositions) the first time it runs, so there's always something to drill.

Run drill next to print the current word and advance to the next one. Output is counter/total (the word's own 1-based position in the list) and the drill sign, together in yellow, a space, then the word (colored by its show count — see "Mastery" below), all on one line:

1/6⟳ apple

(word 1 of 6 total)

Shell integration (one-time setup)

Add one line to your shell's rc file so the current word prints automatically before each prompt. This is a one-time step — the line stays in your rc file and takes effect in every new shell session from then on; you never need to run it again by hand.

Add it near the end of the file, after anything that sets up your PATH (rvm/rbenv init, Homebrew, etc.) — otherwise the shell may not know where drill lives yet when this line runs. Guarding it with command -v also means it never errors, even on a machine/session where drill isn't on PATH for some reason.

zsh — add to ~/.zshrc:

if command -v drill >/dev/null 2>&1; then
  eval "$(drill hook zsh)"
fi

bash — add to ~/.bashrc:

if command -v drill >/dev/null 2>&1; then
  eval "$(drill hook bash)"
fi

Then open a new shell (or source ~/.zshrc / source ~/.bashrc) to pick it up.

Hook not firing in new terminal windows/tabs (rvm users)?

Run drill inspect first — if it shows Toggle: enabled and a valid words file, the tool itself is fine and the problem is that the hook function never got registered in that session.

A common cause with rvm: rvm's installer sometimes puts its actual PATH-loading line ([[ -s "$HOME/.rvm/scripts/rvm" ]] && source "$HOME/.rvm/scripts/rvm") in ~/.zlogin (zsh) or ~/.bash_profile (bash). Those files only run for login shells — but many terminal emulators (e.g. kitty) open new windows/tabs as non-login interactive shells, which only source ~/.zshrc/~/.bashrc. So rvm's gemset bin/ directory (where drill lives) never makes it onto PATH in those sessions, even though everything looks correctly configured.

Fix: move that line into ~/.zshrc / ~/.bashrc (before the drill hook block above, so it runs first) instead of relying on it only being in ~/.zlogin / ~/.bash_profile. It's safe to leave it in both places.

Word only shows once per session, then stops (bash)

The bash hook works by appending a $(drill_precmd) command substitution to PS1, which bash re-evaluates on every prompt render — this is deliberate, since some environments (notably Google Cloud Shell's bashrc.google) snapshot and unconditionally overwrite PROMPT_COMMAND after your .bashrc runs, silently dropping anything hooked in that way. If you installed an older version of the hook and see the word print exactly once and never again, re-run drill hook bash and update the line in your .bashrc to the current snippet.

Rhythm (beat)

By default next just advances one word at a time. You can instead have it repeat loops of consecutive words — e.g. a loop of 3 words shown twice each before moving on:

drill beat 3 2     # loop size 3, repeat each loop 2 times

drill beat none turns it back off (plain word-by-word again).

There are also named shortcuts for common loop sizes, one word/phrase apart:

Loop size Alias
2 polka
3 waltz
4 rock
5 jazz
6 jiga
7 balkan
8 samba

drill waltz 16 is shorthand for drill beat 3 16. Repetitions default to 8 if you leave them off — drill jazz alone is drill beat 5 8, and drill beat 4 alone is drill beat 4 8.

Frequency (rand)

next fires on every single prompt by default via the shell hook, which can feel noisy in a busy session. drill rand <n> makes it actually show a word only approximately 1-in-n times instead:

drill rand 10   # roughly one in every ten calls
drill rand 1    # back to every time (the default)

This is a global setting applied inside drill next itself — unlike start/stop, it affects every call, hook-triggered or manually typed. On a "skip", next silently does nothing (no output, nothing advances) and exits 0.

Mastery (color by show count)

Every time next shows a word it's tracked in ~/.drill/stats (see drill stats below). By default (drill color default), the word's color follows a blue → red gradient, one step per 10 shows — so at a glance, blue words are fresh and red words are heavily drilled. Once a word hits 120 shows it's considered mastered and next stops selecting it (it still appears in drill list/drill stats, just no longer comes up automatically). If every word in the list has been mastered, next reports that on stderr and exits 1 instead of showing anything.

Prefer a vivid random color every time instead of the gradient? Run drill color random (and drill color default to switch back — this is the default, so it's also the way to undo random).

Google Sheets export

drill export <sheet-name> mirrors your word list (text only, no stats) into a tab of a Google Sheet you own; drill import <sheet-name> reads it back. There are two ways to authenticate — pick whichever you prefer:

  • drill remote <url> — a service account you create yourself. No interactive sign-in, ever; you share the spreadsheet with the service account's email address once, and drill uses a local private key file to sign its own requests.
  • drill oauth <url> — your personal Google login, via a one-time interactive OAuth approval. No GCP service account needed, but you (or anyone using the gem) has to click through a sign-in flow once.

If both are configured, whichever you set more recently wins — running drill oauth <url> after drill remote <url> switches export/import over to the personal-login flow, and vice versa.

Either way, export always overwrites the named tab (creating it first if it doesn't exist yet) with the current word list, one phrase per row, so it stays an exact mirror even if the list shrinks. import is the reverse: it reads column A of the tab (ignoring any other columns, like an old export's show counts) and replaces your local ~/.drill/words — useful for editing the list in Sheets and pulling changes back down, or seeding a fresh machine from an existing sheet.

Option A: service account (drill remote)

No embedded secret, no interactive consent screen — but it does require a one-time Google Cloud Console setup, and the resulting private key file is a real secret you must keep local.

  1. Go to https://console.cloud.google.com, select a project (reuse the one from Option B if you've already set that up), and make sure Google Sheets API is enabled (APIs & Services → Library).
  2. IAM & Admin → Service Accounts → "+ CREATE SERVICE ACCOUNT". Name it anything (e.g. lexdrill-export); skip granting it any project-level roles — it doesn't need any, since access comes from sharing the document directly.
  3. Click into the new service account → Keys → Add Key → Create new key → JSON → Create. This downloads a .json file — this is a real secret, equivalent to a password. Never commit it, publish it, or share it with anyone.
  4. Note the service account's email (looks like name@your-project-id.iam.gserviceaccount.com — also the client_email field in the downloaded JSON).
  5. Open your target Google Sheet → Share → paste that email → grant Editor access → uncheck "Notify people" → Share.
  6. Save the downloaded key file to ~/.drill/gcp-service-account.json on your machine (drill reads it from that fixed path; it's never embedded in the gem or committed anywhere).
drill remote 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
drill export Sheet1

No sign-in prompt — every call authenticates silently by signing a fresh, short-lived JWT with the local key file.

Option B: personal login (drill oauth)

Uses the OAuth 2.0 Device Authorization Grant ("visit this URL, enter this code," the same style of flow gcloud auth login uses) — you approve access to your own account once, and the resulting token is cached locally at ~/.drill/gcp-token.json (mode 0600), never published or shared.

The OAuth client id/secret embedded in lib/lexdrill/google_auth.rb are not secret for this use — Google's own docs say client credentials for "TVs and Limited Input devices" / installed-app clients aren't treated as confidential (this is fundamentally different from the service account's private key above, which genuinely is a secret). The actual secret is your personal refresh token, generated only after you interactively approve.

  1. Go to https://console.cloud.google.com, create or select a project, enable Google Sheets API.
  2. APIs & Services → OAuth consent screen: User type External; publishing status Testing is fine — add your own Google account under "Test users".
  3. APIs & Services → Credentials → Create Credentials → OAuth client ID → Application type "TVs and Limited Input devices" (no redirect URI needed). Copy the generated Client ID and Client secret into CLIENT_ID/CLIENT_SECRET in lib/lexdrill/google_auth.rb.
  4. "Testing" publishing status has historically imposed a 7-day refresh-token expiry for some scopes. If drill export starts asking you to re-authorize every week, switch the consent screen to "In production" — for the spreadsheets scope (not a "restricted" scope) this just adds an "unverified app" click-through on first consent, no Google review required.
drill oauth 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
drill export Sheet1

The first export/import prints a URL and a short code — visit it, sign in with the Google account that has edit access to that spreadsheet, and approve. Every call after that is silent (the cached token refreshes itself automatically).

Commands

Command What it does
drill next Print the current word and advance
drill start / drill stop Pause/resume the automatic per-prompt hook (doesn't affect manual next)
drill inspect Show the active config directory, word count, counter value, toggle, beat, rand, and color state
`drill hook zsh\ bash` Print the shell integration snippet (used above)
drill beat <2-8> <repetitions> / drill beat none Set or disable the rhythm
`drill polka\ waltz\ rock\ jazz\ jiga\ balkan\ samba ` Shorthand for a fixed loop size (see table above)
`drill color random\ default` Color each shown word randomly, or by its show count (default)
drill add <text> Append a new item to the end of the list
drill list Show how many times each item has been shown, numbered
drill open Open the list file in $EDITOR/$VISUAL (falls back to vi)
drill stats Print all items as <count>\t<phrase> (tab-separated), sorted by show count, highest first
drill rand <n> drill next shows a word ~1-in-n times (n=1 is every time, the default)
drill go <number> Jump so the next drill next shows item <number> (1-based, see drill list) — prints nothing itself; refuses a graduated item; has no effect while drill beat rand is active, since that mode ignores the counter entirely
drill remote <url> Set the Google Sheet used by the service account flow (global, parses the spreadsheet id out of a normal share URL); whichever of drill remote/drill oauth was set more recently wins
drill oauth <url> Set the Google Sheet used by the OAuth (personal-login) flow (global, parses the spreadsheet id out of a normal share URL); whichever of drill remote/drill oauth was set more recently wins
drill sheet Print a link to whichever spreadsheet (drill remote/drill oauth) is currently active
drill export <sheet-name> Export the word list text to the named tab (created if it doesn't exist), overwriting it; uses whichever of drill remote/drill oauth was configured most recently (first OAuth use triggers a one-time Google device-flow sign-in)
drill import <sheet-name> Replace the local word list with column A of the named tab