Fuzzy-find slash commands and skills in the '/' typeahead

Personal dotfiles + provisioning scripts for a brand new machine:
setup.sh — Debian/Ubuntu (apt)setup.mac.sh — macOS (Homebrew)git clone <this-repo-url> ~/dotfiles
cd ~/dotfiles
./setup.sh # Debian/Ubuntu
./setup.mac.sh # macOS
Both scripts are organized into one section per tool, check whether each tool is already installed before doing work again (safe to re-run), and back up (never delete) any existing config file before replacing it with a symlink. If any step fails, the script prints exactly which step, command, and line failed and stops — fix the issue and re-run it; completed steps are skipped automatically.
setup.sh installs.zshrc, .vimrc, .tmux.conf, starship.toml, ghostty.config, herdr_config.toml, nvim/, yazi/*)git config --global core.editor nvimlazy.nvim) and yazi plugins (ya pkg install)setup.mac.sh installsSame set, via Homebrew, minus what macOS already provides:
zsh-autosuggestions (the brew formula, which is what .zshrc.mac sources)~/.cargo/env exists for .zshrc.mac)$(brew --prefix)/bin).zshrc.mac as ~/.zshrcgit config --global core.editor nvimDifferences from the Linux script, all deliberate:
pbcopy; compilers come from the Command Line Tools.bat→batcat / fd→fdfind renames, so no ~/.local/bin shims.fastfetch takes its place..zshrc.mac's hardcoded /usr/local paths or /Users/jackyyeh home directory don't match this machine (they don't on Apple Silicon).A few things are deliberately not automated because they're either interactive, machine/version-specific, or risky to guess at:
chsh didn't run automatically (e.g. non-interactive session), run: ``bash chsh -s $(command -v zsh) `` then log out and back in.setup.sh adds you to the docker group, but that only takes effect after you log out/in (or run newgrp docker).setup.mac.sh installs the cask, but you must launch it once from /Applications before the docker CLI exists./usr/local/go, matching the PATH entry already in .zshrc.linux).bash nvm install --lts ` Note: .zshrc.linux also has a hardcoded PATH entry pointing at a specific past node version directory — harmless if it doesn't exist, just update it to match whatever nvm installs if you want node/npm always on PATH` without loading nvm.setup.sh only symlinks ghostty.config into ~/.config/ghostty/config. On macOS setup.mac.sh installs the cask for you.bash ssh-keygen -t ed25519 -C "your_email@example.com" ``setup.sh best-effort bootstraps plugins headlessly; if that step warned, just open nvim once and let lazy.nvim finish, then Mason will install LSP servers as configured in nvim/lua/plugins.lua.hooks/register.ts 83 lines1import type { CommandInfo, PromptAutocompleteSuggestion, Register } from 'claude-code'
2
3const MAX_ROWS = 10
4
5// The fullscreen '/' menu is 5 lines tall, and a row whose description does
6// not fit beside its name wraps to 2 lines, so long skill descriptions leave
7// room for only 2 rows. Cut each to one short line so 5 rows fit.
8const MAX_DESC = 50
9
10// First sentence, at most MAX_DESC columns.
11// e.g. shorten('Writes a commit message. Use when...') -> 'Writes a commit message.'
12export const shorten = (d: string): string => {
13 const s = d.replace(/\s+/g, ' ').trim()
14 const end = s.search(/[.!?:](\s|$)/)
15 const first = end > 0 ? s.slice(0, end + 1) : s
16 return first.length <= MAX_DESC ? first : first.slice(0, MAX_DESC - 1).trimEnd() + '…'
17}
18
19// fzf-style subsequence score of `query` in `name`, or undefined when the
20// letters of `query` do not all appear in order. Higher is better: a run of
21// consecutive letters, a hit right after `-`/`:`/`_`/`.`, and a hit at the
22// start each earn a bonus; every skipped letter costs a little.
23//
24// e.g. score('cr', 'code-review') hits c(0) and r(5, after '-') -> scores;
25// score('cr', 'clear') hits c(0) and r(4) -> lower, no boundary bonus.
26export const score = (query: string, name: string): number | undefined => {
27 const q = query.toLowerCase()
28 const n = name.toLowerCase()
29 let total = 0
30 let qi = 0
31 let prev = -2
32 for (let i = 0; i < n.length && qi < q.length; i++) {
33 if (n[i] !== q[qi]) continue
34 let s = 1
35 if (i === 0) s += 8
36 else if ('-:_./ '.includes(n[i - 1])) s += 6
37 if (i === prev + 1) s += 5
38 else if (prev >= 0) s -= Math.min(i - prev - 1, 3)
39 total += s
40 prev = i
41 qi++
42 }
43 if (qi < q.length) return undefined
44 // Prefer shorter names when the hits tie.
45 return total - n.length * 0.01
46}
47
48// The engine's own rows already cover names that start with the query; add
49// only the fuzzy hits it misses, best first.
50export const fuzzyRows = (query: string, commands: readonly CommandInfo[]): PromptAutocompleteSuggestion[] => {
51 const q = query.toLowerCase()
52 const seen = new Set<string>()
53 return commands
54 .filter(c => !c.name.toLowerCase().startsWith(q))
55 .map(c => ({ c, s: score(q, c.name) }))
56 .filter((x): x is { c: CommandInfo; s: number } => x.s !== undefined)
57 .sort((a, b) => b.s - a.s)
58 .filter(({ c }) => !seen.has(c.name) && !!seen.add(c.name))
59 .slice(0, MAX_ROWS)
60 .map(({ c }) => ({ text: `/${c.name} `, label: `/${c.name}`, description: shorten(c.description) }))
61}
62
63export const register: Register = on => {
64 // Descriptions are cached per session; drop the cache so a reload applies.
65 on('session.start', ($, e, next) => {
66 $.ui.invalidate('command.describe')
67 return next(e)
68 })
69
70 on('command.describe', async ($, e, next) => {
71 const r = await next(e)
72 return { ...r, description: shorten(r.description) }
73 })
74
75 on('prompt.autocomplete', { token: /^\/./ }, async ($, e, next) => {
76 const base = await next(e)
77 // Slash commands only count at the very start of the prompt.
78 if (e.text.slice(0, e.start).trim() !== '') return base
79 const rows = fuzzyRows(e.token.slice(1), await $.command.list())
80 return { suggestions: [...base.suggestions, ...rows] }
81 })
82}
83