cdai - the AI-enhanced cd command
Jump to the directory you mean, even if you have never visited it before.
Install
Requires Node.js 20+. Homebrew reuses an active compatible Node on PATH, including one activated
by nvm, fnm, asdf, Volta or mise. Otherwise it uses an already-installed Homebrew Node or installs
one automatically.
Homebrew is the recommended one-command install:
brew install franzenzenhofer/tap/cdai
See how it works
The GIF runs the real completion, picker, AI validation, confirmation and alias paths. Its AI
backend is a deterministic local shim from docs/demo-fixture.sh, so the recording is repeatable
and never needs network access or model credentials. Re-record it with
sh docs/demo-fixture.sh && vhs docs/demo.tape (requires vhs and fzf).
Finish setup
Enable cdai in your shell:
| Shell | Config file | Add this line |
|---|---|---|
| zsh | ~/.zshrc |
eval "$(cdai init zsh)" |
| Bash | ~/.bashrc |
eval "$(cdai init bash)" |
| Fish | ~/.config/fish/config.fish |
cdai init fish | source |
Then start a new shell and finish setup:
exec "$SHELL" cdai setup cdai doctor
Prefer npm? With Node.js 20+ installed:
npm install -g github:franzenzenhofer/cdai
Coming from zoxide? cdai import zoxide seeds the local frecency database. For explicit,
non-interactive setup:
cdai setup --root "$HOME/dev" --depth 3 --yes --no-aicdai keeps normal cd behavior, adds a local directory index and frecency, understands intent
such as latest or 2025, and can use an optional AI fallback that is not allowed to invent a
path. The fast path and every Tab completion are deterministic and model-free.
The 30-second version
$ cdai pet<Tab> $ cdai petalworks → ~/Dropbox/clients/petalworks $ cdai latest petalworks folder → ~/Dropbox/clients/petalworks/petalworks-2026 $ cdai petalworks 2025 → ~/Dropbox/clients/petalworks/petalworks-2025 $ cdai that client with the flowers cdai: thinking... (apfel) cdai: ~/Dropbox/clients/petalworks (petalworks = flowers-themed client name) [Y/n] → ~/Dropbox/clients/petalworks # the confirmed wording is now a local, model-free alias $ cdai that client with the flowers → ~/Dropbox/clients/petalworks
| Property | What cdai does |
|---|---|
| Native behavior | Tries your shell's real cd first; paths, flags, cd -, CDPATH and errors stay native |
| Cold directories | Indexes configured roots, so unvisited folders are searchable |
| Smart Tab | Completes prefixes, compact forms and bounded typos from local cached state |
| Common intent | Handles latest, oldest, years and in <root> without AI |
| Vague intent | Optionally asks an AI to choose from existing, pre-approved paths |
| Runtime footprint | One bundled Node 20+ executable, zero runtime npm dependencies |
Everyday usage
| You type | What happens |
|---|---|
cdai petal |
jump to the best name match, ranked by context and frecency |
cdai latest petalworks folder |
open the newest child directory, by modification time |
cdai oldest petalworks |
open the oldest child directory |
cdai petalworks 2025 |
require 2025 somewhere in the matched path |
cdai squash in dev |
restrict the search to the matching configured root |
cdai https://tidewheel.orbit.dev |
jump to the project behind a pasted URL |
cdai https://github.com/octocat/tidewheel |
a link is named by what it points at, then by its host |
cdai file:///Users/me/dev |
cd into a directory pasted as a file:// URL |
cdai open this ~/dev/2026-09-07/newsletter.html |
a path pasted among the words wins, and a file means its directory |
cdai alias add -- the nudge game |
remember this directory under a name nothing could guess |
cdai -P petal |
resolve the match to its physical path, following symlinks |
cdai ./cdai/src |
a path cd cannot take names a place, scoped by the folders above it |
cdai ~/some/dir |
native cd first; a path it can take is never guessed |
cdai - |
use native cd - to return to the previous directory |
cdai |
use native cd to return home |
Smart Tab completion
Tab merges the shell's native directories and CDPATH with cdai's cached index, frecency, current directory context and confirmed aliases.
$ cdai gma<Tab> # goalmap: compact subsequence $ cdai petla<Tab> # petalworks: bounded typo correction $ cdai latest pet<Tab>
Prefix results may fan out, but a non-prefix correction must have one clear winner. Ambiguous or unrelated text is left untouched. Duplicate names complete to the shared basename and are disambiguated by the picker after Enter.
Tab reads local cached state only. It never calls AI, opens a picker, refreshes the index, ingests history or crawls the filesystem.
Native cd behavior and flags
The shell wrapper always gives native behavior the first chance:
cdai,cdai -, explicit paths, CDPATH and zsh'scd old newsubstitution stay native.- Nothing fails on a
cderror before every other tier has spoken. A path the builtin refuses is read as what it names inside the folders written above it - those gate the candidates without competing for the match - and a path landing on a file means the directory holding it, socdai ./cdai/src,cdai ./dev/squaandcdai open this ~/2026-09-07/newsletter.htmlall land. - The builtin's own error is the last word, not the first: it is printed only when the index,
memory and AI all had nothing, and only where
cdcould have taken those words at all. -Lpreserves the logical symlink path;-Presolves symlinks to the physical path. Both compose with intent in zsh/Bash, and Fish support is feature-detected by version.- Stack syntax and late or invalid flags are never guessed.
- Existing local directories win even when their name is also a cdai command.
- Human messages go to stderr;
cdai queryreserves stdout for the resolved path.
Cache migrations happen automatically. After an upgrade, start a new shell to load the latest
wrapper; cdai doctor reports stale or partial state and the exact repair command.
Command reference
cdai [cd-options] <words> native cd first, then indexed/remembered/AI intent
cdai <explicit/path> native cd first; its error is kept only if nothing else answers
cdai <words> <path> a pasted path wins; a file resolves to its directory
cdai query -- <words> resolve only, prints the path on stdout
cdai init <zsh|bash|fish> print the shell integration, meant for eval
cdai setup [--yes] [--ai|--no-ai] [--root <path>] [--depth <1-64>]
[--remove-root <path>]
detect or add roots and choose optional AI fallback
cdai index [--refresh] show or rebuild the directory index
cdai import zoxide seed frecency from an existing zoxide database
cdai alias list show confirmed local intent aliases
cdai alias add [<path>] -- <words>
remember a directory, defaulting to the current one, under a
name no matcher could guess
cdai alias forget -- <words>
forget a mistaken confirmed alias
cdai doctor show what cdai sees on this machine
cdai --version
Exit codes: 0 success, 3 a navigation choice was deliberately aborted, anything else is an
error. stdout carries the resolved path and nothing else; every human-readable byte goes to
stderr.
Teaching a name yourself
Some names are unguessable: a game called notchi is not spelled nudge, and no matcher or
model gets there from the letters. Name it once and it is a local, model-free alias from then on:
$ cd ~/dev/games/notchi $ cdai alias add -- the nudge game cdai: "the nudge game" -> ~/dev/games/notchi $ cdai the nudge game → ~/dev/games/notchi
Filler words do not have to match: cdai nudge game and cdai go to the nudge game folder find
the same alias, because both read as the same intent.
cdai alias add ~/dev/games/notchi -- the nudge game names a directory you are not standing in.
The directory has to exist and live under a configured root, because an alias outside the roots
is dropped the moment it is used - so cdai refuses it up front instead of forgetting it later.
cdai alias list shows every remembered name, cdai alias forget -- <words> removes one.
Why this exists
I use zoxide, and cdai deliberately uses the same frecency formula. But frecency can only rank places already in its history. My awkward case is the opposite: client and archive folders I may open once a year. The first jump is still manual.
cdai indexes only the roots you configure, then uses frecency to rank that known tree. That makes a never-visited directory a first-class candidate.
| Capability | zoxide | cdai |
|---|---|---|
| frecency ranking | yes | yes, same aging formula |
| learns from your shell | yes | yes, prompt/PWD hook, no Node process on directory change |
| indexes directories you have never visited | no | yes, configurable roots and depth |
latest / oldest / year / in <root> |
no | yes, deterministic, no LLM |
| natural language fallback | no | optional, one config flag to kill it |
| runtime requirement | standalone binary | Node 20+; zero npm dependencies |
| cold jump on an unvisited folder | miss | hit |
Use zoxide if visited-directory frecency is the whole problem; it is mature, fast and ships as a static binary. Use cdai if cold directories, deterministic intent and guarded natural-language fallback are useful enough to justify a Node executable.
The AI cannot hallucinate a directory
Giving an LLM arbitrary control of your working directory would be a terrible idea. cdai uses a closed-set protocol instead:
- The deterministic matcher supplies up to 30 fuzzy and 20 frecent existing paths.
- The model may choose one exact path from that list, or decline.
- cdai checks the answer against both the original list and the filesystem.
- You confirm the first answer; only then can the wording become a local alias.
cdai emits its own trusted candidate, never the model's spelling. Even an existing path is rejected if it was not offered.
That makes the failure mode boring on purpose. Two measured runs of the same query:
# frecency db empty, no fuzzy candidates -> nothing to choose from $ cdai that client with the flowers cdai: no match for "that client with the flowers" searched 2540 directories under 2 roots, freshly scanned not there? `cdai setup --root <path>`, or reach deeper with `--depth <n>` # same query, after petalworks is in the history $ cdai that client with the flowers cdai: thinking... (apfel) cdai: ~/Dropbox/clients/petalworks (petalworks = flowers-themed client name) [Y/n]
The model is a re-ranker over a set you could print yourself, not a path generator. A missing backend, timeout, malformed answer or chatty model degrades to fuzzy suggestions. Confirmed answers are stored as bounded local aliases and revalidated before reuse; deterministic matching still wins if the tree later gains a better direct match.
Corollary, stated plainly: cdai does not do semantic search over your whole disk. If the directory is neither a fuzzy candidate nor recently used, no amount of LLM will find it.
Measured performance
Measured on an Apple Silicon laptop (macOS 26, Node 25) against an index of about 2,500
directories. Best of 10 runs, full process spawn to exit, spawnSync from a Node harness:
| min | median | |
|---|---|---|
bare node -e "" (the floor) |
67ms | 73ms |
cdai <exact hit> |
95ms | 105ms |
cdai latest <name> |
95ms | 102ms |
cdai <no deterministic match> (tier 2 fires) |
7.8s | 8.3s |
So tier 1 costs about 30ms of actual work; the rest is Node booting. Tier 2 costs seconds,
which is exactly why the thresholds are tuned to avoid it. test/latency.test.ts fails the
build if the 10-run median crosses 150ms or p95 crosses 250ms for either an exact query or
cached Tab completion.
Reproduce with npm run build && npx vitest run test/latency.test.ts.
The v0.3.10 release suite covers 247 tests. CI runs on macOS and Linux with Node 20, 22 and 24; real PTYs exercise Zsh, Bash, Fish 3.6 and Fish 4.8; a synthetic 50,000-entry index has its own completion budget; and the packed tarball is installed and executed instead of testing only the source tree.
How it works
cdai latest petalworks folder
│
▼
┌───────────────────────┐
│ tokenize │ operators: latest/oldest, 2026, "in dev"
│ │ stopwords: folder, dir, the, project, go, to, my,
│ │ of, a, an, for, from
│ │ URLs: literal first, then
│ │ orbit.dev/tidewheel -> tidewheel, orbit
└───────────┬───────────┘
▼
┌───────────────────────┐ ┌──────────────────┐
│ tier 1: deterministic │◀───────│ index.json dirs │ config-aware, rescanned on a miss
│ fuzzy + frecency │◀───────│ db + aliases │ visits + confirmed intent
└───────────┬───────────┘ └──────────────────┘
│
score >= 550 │ 2+ candidates nothing convincing
and gap >= 200│ >= 400 │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌──────────────────────┐
│ jump │ │ picker │ │ tier 2: ai (optional)│ Apfel/Claude/Gemini/Ollama,
│ exit 0│ │ fzf │ │ exact offered path │ 45s cap, bounded output,
└────┬───┘ └───┬────┘ └──────────┬───────────┘ validated before use
▼ ▼ ▼
stdout: /the/path stderr: → ~/the/path
Tier 1 is the product. Every directory name gets a match class - exact 1000, prefix 800,
word boundary 600, substring 400, fuzzy up to 380 - plus 100 * log2(1 + frecency) and a small
bonus for living under your current directory. All tokens must match (AND). A directory and its
own parent collapse into one answer, because they are the same place, not two options. Every
threshold in the diagram lives in one small file: src/match/constants.ts.
A rescan is never a question. Rebuilding the whole index costs about 130ms for 2,500 directories - a fraction of the AI call it precedes - so cdai decides instead of asking, and the moment is always the same: the one where new data could still change the answer.
| moment | rescan | why |
|---|---|---|
| config changed | yes | the index answers a different question now |
| a deterministic hit, or a picker | no | there is nothing to gain |
| a confident hit on a path that vanished | yes | the index is wrong, not incomplete |
| nothing answered, after alias recall | yes, once | a folder made since the last scan is invisible however recent that scan was |
| the AI tier | never separately | it runs on the index the rescan just built, so it can name a folder made a second ago |
| Tab completion | never | keystroke latency; Tab reads cached state only |
That leaves exactly one thing a rescan cannot fix, and it is what the miss reports: a folder
outside your roots or below your depth. cdai setup --root <path> and --depth <n> are the only
levers left, so those are the words on the screen - never "try reindexing", which just happened.
A URL is read again as the names it carries. The word you typed always goes first, so a
folder literally called nordwind.at or amt.gv.at still wins outright. Only when the literal
word matches nothing does cdai read the link, most specific name first: what it points at, then
what hosts it. https://github.com/octocat/tidewheel searches for tidewheel, then octocat -
nobody's project is called github, so the platforms name nothing. https://franzai.com/writer
searches for writer before franzai, and https://tidewheel.orbit.dev/level/7 searches for
tidewheel, then orbit, because level/7 only numbers a page. lumenlab.com,
www.lumenlab.com and https://www.lumenlab.com/blog all still search for lumenlab, so
cdai lumenlab.com website lands on ~/dev/lumenlab-website without calling a model.
A URL is intent, never a path: pasting one, alone or inside a sentence, never reaches cd, which
could only fail on it. The host reading needs a real public suffix, so node.js and
vite.config are never anything but literal names.
AI backends
On Apple Silicon with macOS 26+, brew install apfel adds a private, on-device fallback. It
uses Apple's system model and requires Apple Intelligence, but no API key or separate model
download.
The default "command": "auto" chooses the first installed backend in this order:
apfel- Apple's on-device Foundation Model; no model setting needed.claude- Claude Code in print mode; defaults tosonnet.gemini- Gemini CLI in headless mode; uses its configured default model.
Ollama is deliberately not guessed because cdai cannot know which local model you want. Select it with a model name:
{ "ai": { "enabled": true, "command": "ollama", "model": "qwen3:4b", "timeoutMs": 45000 } }To pin a built-in backend, set command to apfel, claude, or gemini. Existing Claude
configs keep working unchanged. args adds backend-specific flags without invoking a shell.
Any other one-shot CLI also works. command is the executable name or path; each args entry
is one argument. {prompt} and {model} placeholders are expanded in place, and the prompt is
appended when {prompt} is absent:
{
"ai": {
"enabled": true,
"command": "my-ai",
"args": ["run", "--model", "{model}", "--prompt", "{prompt}"],
"model": "small",
"timeoutMs": 45000
}
}cdai understands bare model JSON and the response envelopes emitted by Apfel, Claude, Gemini, and OpenAI-compatible tools. Backend output is capped at 1 MiB, calls time out and terminate the backend process group, control text is removed from displayed reasons, and every failure falls back to deterministic suggestions.
One question, not a coding session
A cd is one classification, so the Claude backend is invoked as one: --safe-mode and
--strict-mcp-config keep your CLAUDE.md, settings, skills, hooks and MCP servers out of it,
--system-prompt replaces the agent persona with a path classifier, and --json-schema makes
the CLI itself enforce the {"path", "reason"} shape. Without them the same query booted a full
agent session - measured at 66k-114k input tokens, 4-8 seconds and up to $0.11 per cd, and
answering in prose often enough that cdai reported ai had no usable answer. Hardened, the same
query costs about 2k tokens and 2-3 seconds, and the answer arrives pre-validated.
When a backend does fail, it gets to say why: a non-zero exit quotes the first line the backend
wrote to stderr (claude exited with 1: error: unknown option '--safe-mode'), and unparseable
output is quoted back instead of being summarised as "unparseable". CDAI_DEBUG=1 cdai <words>
prints the backend's full raw output to stderr.
An AI answer is only ever acted on after you say yes, and that consent fails closed in every
direction: no terminal declines, and a terminal that closes before answering declines too.
Setup states which backend was selected and that vague queries plus candidate paths may be sent
to it. Use cdai setup --no-ai during or after setup to opt out, and --ai to re-enable it.
Turning AI off entirely
Run cdai setup --no-ai (or set ai.enabled to false) and cdai is a fast fuzzy jumper with
frecency, operators, and any previously confirmed local aliases. Tier 1, Tab, and alias lookup
make no network call under any configuration. The full suite passes with no AI backend on
PATH; tier 2 tests drive executable shim scripts, so cloning this repo never spends a token.
The chosen backend receives the words you typed, your cwd, and up to 50 in-root directory paths - never file contents. With Apfel or Ollama that stays local; cloud-backed CLIs apply their own privacy and billing policies.
Configuration
~/.config/cdai/config.json (override with CDAI_CONFIG_DIR, data with CDAI_DATA_DIR):
{
"roots": [
{ "path": "/Users/you/dev", "depth": 2 },
{ "path": "/Users/you/Dropbox/clients", "depth": 3 }
],
"ignore": ["node_modules", ".git", "dist", "build", ".venv"],
"ai": { "enabled": true, "command": "auto", "args": [], "model": "", "timeoutMs": 45000 }
}Data lives in ~/.local/share/cdai/: index.json (capped at 50,000 entries and a five-second
walk), db.json (frecency, capped at 10,000 paths), aliases.json (confirmed intent, capped at
256), and visits.log (append only, ingested transactionally on the next navigation query).
Config/state directories are mode 0700 and files 0600; each invocation tightens permissions
from older installs. The index carries a roots/depth/ignore fingerprint, reports partial crawls,
and is rebuilt automatically when that configuration changes. Concurrent shells serialize
short atomic updates so visits and aliases are not lost or double-counted.
Limitations
- No Windows. zsh, bash and fish on macOS and Linux.
- The index is a snapshot, but never a stale answer: it is rebuilt whenever nothing answered, so a folder made seconds ago is found without being told to reindex. What a rescan cannot fix is a folder outside your roots or below your depth, and the miss says so.
- Crawl depth is bounded by your config (and capped at 64). Deep monorepos need a deeper root, and a deeper root means a bigger index.
- Tier 2 is seconds, not milliseconds, and needs a working CLI backend. It is off the hot path by design, not by accident.
- No semantic search over unindexed directories. See the section above.
- Newline-bearing directory names are not supported. Shell query/completion output is newline-delimited, so such paths are excluded instead of being emitted ambiguously.
- Small TypeScript codebase. This is one focused tool, not a platform.
Development
npm run typecheck && npm run lint && npm run test && npm run build
No mocking library and no fake filesystem. Fixtures are real temp trees containing spaces,
unicode, symlinks, an unreadable directory and a node_modules. The AI tier uses executable
shim processes; zsh, Bash, older Fish, and current Fish run end to end, with real PTY Tab tests
for all three shells. Exact-query and cached-completion median/p95 latency remain hard build gates.
The packed tarball is installed and executed in the suite. Zero runtime dependencies; the build
is one bundled dist/cdai.js.
Releasing
Homebrew installs from a GitHub release tarball, so a merged fix does not reach anyone until a
tag exists and the tap points at it. A local npm link of this repo is a second copy of cdai
on PATH; whichever of /opt/homebrew/bin and the Node bin dir comes first wins, so always
check cdai --version after upgrading rather than assuming.
- Bump
versioninpackage.json. Nothing else hardcodes it -src/cli.tsand the tests read it from there. npm run typecheck && npm run lint && npm run test && npm run build, then commitdist/cdai.jswith the source. CI enforces that the committed bundle matches a fresh build.- Push
main, thengit tag -a vX.Y.Z -m "..."and push the tag. - In franzenzenhofer/homebrew-tap,
Formula/cdai.rb: pointurlat the new tag, setsha256toshasum -a 256of that tarball, and drop anyrevisionline. Push. brew update && brew upgrade cdai, then confirmcdai --version.
Prior art
zoxide and z for frecency, fzf for the picker, vhs for the demo. cdai's only original claim is the combination: a crawled index so cold folders are reachable, deterministic operators so common intent never needs a model, and an LLM confined to re-ranking a closed candidate list.
License
MIT
