franzenzenhofer/cdai

2

stars

3

commits

TypeScript

primary language

Sep 7, 2026

updated

README

cdai - cd with intent

A directory jumper that indexes folders you have never visited, understands latest, oldest and year filters deterministically, and only asks an LLM when its own matcher admits it is unsure.

The AI tier cannot invent a path. It picks from a list, or it declines. More on that below, because it is the only genuinely interesting thing in here.

$ cdai petal
→ ~/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... (claude sonnet)
cdai: ~/Dropbox/clients/petalworks (petalworks = flowers-themed client name) [Y/n]
→ ~/Dropbox/clients/petalworks

Every one of those is real output, produced by running the binary against the tree that docs/demo-fixture.sh builds. Reproduce it yourself with sh docs/demo-fixture.sh && vhs docs/demo.tape. The last one additionally needs that client in your history - see below for why, and for what it prints when it is not.

cdai demo

Why not just zoxide

I use zoxide and cdai steals its frecency formula on purpose. But zoxide can only rank directories you have already cd'd into, and my problem is the opposite one: freelance client folders I visit once a year. A pure frecency tool has never seen them, so they do not exist. The first jump is always the manual one.

cdai keeps a crawled index of configured roots, so a folder you have never opened is a first class candidate. Frecency then reorders what the index found.

zoxidecdai
frecency rankingyesyes, same aging formula
learns from your shellyesyes, chpwd hook, zero subprocesses
indexes directories you have never visitednoyes, configurable roots and depth
latest / oldest / year / in <root>noyes, deterministic, no LLM
natural language fallbacknooptional, one config flag to kill it
runtime dependenciesRust binaryNode, zero npm dependencies
cold jump on an unvisited foldermisshit

The part that matters: the AI cannot hallucinate a directory

An LLM that picks your working directory is a terrible idea if it can emit arbitrary strings. So it cannot. Tier 2 gets a closed list - the top 30 fuzzy candidates plus your 20 most frecent paths - and the reply contract is one JSON object naming a path from that list. The answer is then re-checked: it must exist on disk and live under a configured root, or it is thrown away.

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: thinking... (claude sonnet)
cdai: ai had no usable answer (no candidates provided to match)
cdai: no match for "that client with the flowers"

# same query, after petalworks is in the history
$ cdai that client with the flowers
cdai: thinking... (claude sonnet)
cdai: ~/Dropbox/clients/petalworks (petalworks = flowers-themed client name) [Y/n]

The model is a re-ranker over a set you could have printed yourself, not a path generator. It is doing the one thing it is good at - "flowers" means "petalworks" - and is structurally prevented from doing the thing it is bad at. A missing backend, a timeout, or a chatty model degrades to fuzzy suggestions, never to a wrong cd.

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.

Numbers

Measured on an Apple M5, macOS 26.1, Node v25.1.0, against a real index of 2467 directories. Best of 10 runs, full process spawn to exit, spawnSync from a Node harness:

minmedian
bare node -e "" (the floor)67ms73ms
cdai <exact hit>95ms105ms
cdai latest <name>95ms102ms
cdai <no deterministic match> (tier 2 fires)7.8s8.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 an exact hit ever crosses 150ms.

Reproduce with npm run build && npx vitest run test/latency.test.ts.

How it works

        cdai latest petalworks folder
                    │
                    ▼
        ┌───────────────────────┐
        │ tokenize              │  operators: latest/oldest, 2026, "in dev"
        │                       │  stopwords: folder, dir, the, project, go, to, my
        └───────────┬───────────┘
                    ▼
        ┌───────────────────────┐        ┌──────────────────┐
        │ tier 1: deterministic │◀───────│ index.json  dirs │  rebuilt on miss, TTL 60min
        │ fuzzy + frecency      │◀───────│ db.json frecency │  fed by the shell hook
        └───────────┬───────────┘        └──────────────────┘
                    │
      score >= 550  │  2+ candidates       nothing convincing
      and gap >= 200│  >= 400              │
                    ▼         ▼            ▼
               ┌────────┐ ┌────────┐ ┌──────────────────────┐
               │  jump  │ │ picker │ │ tier 2: ai (optional)│ claude -p, 45s cap,
               │  exit 0│ │  fzf   │ │ answer must exist    │ answer validated against
               └────┬───┘ └───┬────┘ └──────────┬───────────┘ the index 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 that diagram is a named constant in one 77 line file, src/match/constants.ts.

Deterministic operators

You typeYou get
cdai petalbest match by fuzzy score and frecency
cdai latest petalworks foldernewest child directory of the match, by mtime
cdai oldest petalworksoldest child directory
cdai petalworks 2025year token is a required substring
cdai squish in devin <root> restricts the search to one root
cdai ~/some/dirplain cd, no magic, no lookup
cdai -plain cd -, back to the previous directory
cdaiplain cd ~, muscle memory stays intact

Install

npm i -g github:franzenzenhofer/cdai
cdai setup                       # detects your project roots, writes the config
echo 'eval "$(cdai init zsh)"' >> ~/.zshrc
exec zsh

bash: eval "$(cdai init bash)" in ~/.bashrc. fish: cdai init fish | source in ~/.config/fish/config.fish. Coming from zoxide? cdai import zoxide seeds your frecency.

Turning the AI off entirely

Set ai.enabled to false in ~/.config/cdai/config.json and cdai is a fast fuzzy jumper with frecency and operators, nothing else. Tier 1 makes no network call, ever, under any configuration. All 107 tests pass with no AI backend on PATH at all - the tier 2 tests drive executable shim scripts, so cloning this repo never spends a token.

When it is on, what leaves your machine is: the words you typed, your cwd, and up to 50 directory paths (no file contents, ever). ai.command is just a command line, so any prompt capable CLI works:

{ "ai": { "enabled": true, "command": "claude", "model": "sonnet", "timeoutMs": 45000 } }

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": "claude", "model": "sonnet", "timeoutMs": 45000 }
}

Data lives in ~/.local/share/cdai/: index.json, db.json (frecency), visits.log (append only, ingested and truncated on the next run, so the shell hook spawns no subprocess).

Commands

cdai <words>              jump to the directory you mean
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]        detect project roots and write the config
cdai index [--refresh]    show or rebuild the directory index
cdai import zoxide        seed frecency from an existing zoxide database
cdai doctor               show what cdai sees on this machine

Exit codes: 0 a path was printed and the shell should cd, 3 handled but deliberately no cd (picker aborted, informational command), anything else is an error. stdout carries the resolved path and nothing else; every human readable byte goes to stderr.

Limitations

  • No Windows. zsh, bash and fish on macOS and Linux.
  • The index is a snapshot, rebuilt on a miss or after 60 minutes. A folder created two minutes ago may need cdai index --refresh.
  • Crawl depth is bounded by your config. 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.
  • ~1800 lines of TypeScript. This is a small tool that does one thing, not a platform.

Development

npm run typecheck && npm run lint && npm run test && npm run build

107 tests, no mocking library, no fake filesystem. Fixtures are real temp trees containing spaces, unicode, symlinks, an unreadable directory and a node_modules. The AI tier is tested against real executable shim scripts. The shell integration is tested by running zsh -f and checking which directory it actually ended up in. Zero runtime dependencies; the build is a single 45KB dist/cdai.js from esbuild.

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

Contributors

franzenzenhofer/cdai

2

stars

3

commits

TypeScript

primary language

Sep 7, 2026

updated

README

cdai - cd with intent

A directory jumper that indexes folders you have never visited, understands latest, oldest and year filters deterministically, and only asks an LLM when its own matcher admits it is unsure.

The AI tier cannot invent a path. It picks from a list, or it declines. More on that below, because it is the only genuinely interesting thing in here.

$ cdai petal
→ ~/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... (claude sonnet)
cdai: ~/Dropbox/clients/petalworks (petalworks = flowers-themed client name) [Y/n]
→ ~/Dropbox/clients/petalworks

Every one of those is real output, produced by running the binary against the tree that docs/demo-fixture.sh builds. Reproduce it yourself with sh docs/demo-fixture.sh && vhs docs/demo.tape. The last one additionally needs that client in your history - see below for why, and for what it prints when it is not.

cdai demo

Why not just zoxide

I use zoxide and cdai steals its frecency formula on purpose. But zoxide can only rank directories you have already cd'd into, and my problem is the opposite one: freelance client folders I visit once a year. A pure frecency tool has never seen them, so they do not exist. The first jump is always the manual one.

cdai keeps a crawled index of configured roots, so a folder you have never opened is a first class candidate. Frecency then reorders what the index found.

zoxidecdai
frecency rankingyesyes, same aging formula
learns from your shellyesyes, chpwd hook, zero subprocesses
indexes directories you have never visitednoyes, configurable roots and depth
latest / oldest / year / in <root>noyes, deterministic, no LLM
natural language fallbacknooptional, one config flag to kill it
runtime dependenciesRust binaryNode, zero npm dependencies
cold jump on an unvisited foldermisshit

The part that matters: the AI cannot hallucinate a directory

An LLM that picks your working directory is a terrible idea if it can emit arbitrary strings. So it cannot. Tier 2 gets a closed list - the top 30 fuzzy candidates plus your 20 most frecent paths - and the reply contract is one JSON object naming a path from that list. The answer is then re-checked: it must exist on disk and live under a configured root, or it is thrown away.

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: thinking... (claude sonnet)
cdai: ai had no usable answer (no candidates provided to match)
cdai: no match for "that client with the flowers"

# same query, after petalworks is in the history
$ cdai that client with the flowers
cdai: thinking... (claude sonnet)
cdai: ~/Dropbox/clients/petalworks (petalworks = flowers-themed client name) [Y/n]

The model is a re-ranker over a set you could have printed yourself, not a path generator. It is doing the one thing it is good at - "flowers" means "petalworks" - and is structurally prevented from doing the thing it is bad at. A missing backend, a timeout, or a chatty model degrades to fuzzy suggestions, never to a wrong cd.

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.

Numbers

Measured on an Apple M5, macOS 26.1, Node v25.1.0, against a real index of 2467 directories. Best of 10 runs, full process spawn to exit, spawnSync from a Node harness:

minmedian
bare node -e "" (the floor)67ms73ms
cdai <exact hit>95ms105ms
cdai latest <name>95ms102ms
cdai <no deterministic match> (tier 2 fires)7.8s8.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 an exact hit ever crosses 150ms.

Reproduce with npm run build && npx vitest run test/latency.test.ts.

How it works

        cdai latest petalworks folder
                    │
                    ▼
        ┌───────────────────────┐
        │ tokenize              │  operators: latest/oldest, 2026, "in dev"
        │                       │  stopwords: folder, dir, the, project, go, to, my
        └───────────┬───────────┘
                    ▼
        ┌───────────────────────┐        ┌──────────────────┐
        │ tier 1: deterministic │◀───────│ index.json  dirs │  rebuilt on miss, TTL 60min
        │ fuzzy + frecency      │◀───────│ db.json frecency │  fed by the shell hook
        └───────────┬───────────┘        └──────────────────┘
                    │
      score >= 550  │  2+ candidates       nothing convincing
      and gap >= 200│  >= 400              │
                    ▼         ▼            ▼
               ┌────────┐ ┌────────┐ ┌──────────────────────┐
               │  jump  │ │ picker │ │ tier 2: ai (optional)│ claude -p, 45s cap,
               │  exit 0│ │  fzf   │ │ answer must exist    │ answer validated against
               └────┬───┘ └───┬────┘ └──────────┬───────────┘ the index 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 that diagram is a named constant in one 77 line file, src/match/constants.ts.

Deterministic operators

You typeYou get
cdai petalbest match by fuzzy score and frecency
cdai latest petalworks foldernewest child directory of the match, by mtime
cdai oldest petalworksoldest child directory
cdai petalworks 2025year token is a required substring
cdai squish in devin <root> restricts the search to one root
cdai ~/some/dirplain cd, no magic, no lookup
cdai -plain cd -, back to the previous directory
cdaiplain cd ~, muscle memory stays intact

Install

npm i -g github:franzenzenhofer/cdai
cdai setup                       # detects your project roots, writes the config
echo 'eval "$(cdai init zsh)"' >> ~/.zshrc
exec zsh

bash: eval "$(cdai init bash)" in ~/.bashrc. fish: cdai init fish | source in ~/.config/fish/config.fish. Coming from zoxide? cdai import zoxide seeds your frecency.

Turning the AI off entirely

Set ai.enabled to false in ~/.config/cdai/config.json and cdai is a fast fuzzy jumper with frecency and operators, nothing else. Tier 1 makes no network call, ever, under any configuration. All 107 tests pass with no AI backend on PATH at all - the tier 2 tests drive executable shim scripts, so cloning this repo never spends a token.

When it is on, what leaves your machine is: the words you typed, your cwd, and up to 50 directory paths (no file contents, ever). ai.command is just a command line, so any prompt capable CLI works:

{ "ai": { "enabled": true, "command": "claude", "model": "sonnet", "timeoutMs": 45000 } }

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": "claude", "model": "sonnet", "timeoutMs": 45000 }
}

Data lives in ~/.local/share/cdai/: index.json, db.json (frecency), visits.log (append only, ingested and truncated on the next run, so the shell hook spawns no subprocess).

Commands

cdai <words>              jump to the directory you mean
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]        detect project roots and write the config
cdai index [--refresh]    show or rebuild the directory index
cdai import zoxide        seed frecency from an existing zoxide database
cdai doctor               show what cdai sees on this machine

Exit codes: 0 a path was printed and the shell should cd, 3 handled but deliberately no cd (picker aborted, informational command), anything else is an error. stdout carries the resolved path and nothing else; every human readable byte goes to stderr.

Limitations

  • No Windows. zsh, bash and fish on macOS and Linux.
  • The index is a snapshot, rebuilt on a miss or after 60 minutes. A folder created two minutes ago may need cdai index --refresh.
  • Crawl depth is bounded by your config. 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.
  • ~1800 lines of TypeScript. This is a small tool that does one thing, not a platform.

Development

npm run typecheck && npm run lint && npm run test && npm run build

107 tests, no mocking library, no fake filesystem. Fixtures are real temp trees containing spaces, unicode, symlinks, an unreadable directory and a node_modules. The AI tier is tested against real executable shim scripts. The shell integration is tested by running zsh -f and checking which directory it actually ended up in. Zero runtime dependencies; the build is a single 45KB dist/cdai.js from esbuild.

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

See what people are saying

Contributors

Languages

TypeScript

97.8%

JavaScript

2.2%