Skip to content

Shell Completion

sase completion supports native shell completion for zsh, bash, and fish from the live sase argparse tree, so pressing <TAB> anywhere in the command line offers the right commands, options, static choices, and live values — bead ids with titles, project display names, macro names, and more — with no perceptible latency.

Installed completion uses a small shell-native loader plus a SASE-owned runtime grammar cache. A fresh shell loads the portable loader, the loader asks the active sase executable for a current cached grammar, and cache hits avoid rebuilding the full parser. Static completion after that runs in the shell. Only values — beads, projects, repos, and the like — are fetched live, through a narrow cached fast path. Manual sase completion zsh|bash|fish exports remain snapshots.

Quick Start

Pick your shell and write the script somewhere your shell already scans, or let sase completion install find that place for you:

sase completion install          # detect the shell, write the loader, verify, stamp
sase completion install zsh      # or name one explicitly
sase completion install -d       # dry run: print the plan, touch nothing
sase completion refresh -d       # preview refreshes for existing stamped installs

Open a new shell afterward — completion scripts are read once, at shell startup.

Manual install, if you'd rather manage the file yourself:

sase completion zsh   -o ~/.zfunc/_sase
sase completion bash  -o ~/.local/share/bash-completion/completions/sase
sase completion fish  -o ~/.config/fish/completions/sase.fish

SASE-managed machines can write the same loaders through chezmoi instead:

sase completion deploy-chezmoi -d  # preview source files
sase completion deploy-chezmoi     # write source, commit, push, and apply

deploy-chezmoi renders the bash, fish, and zsh loaders into ~/.local/share/chezmoi/home (override with -s/--source) and removes the old generated stamp source files under dot_sase/completion/stamp/. Runtime stamp metadata now belongs to each host's SASE state. -c/--no-commit writes the source files only, -n/--no-push commits without pulling, pushing, or applying, and -a/--no-apply commits and pushes but skips chezmoi apply.

The generated grammar also understands root -p/--print-command as a no-value global option. That makes short zsh aliases work cleanly with completion:

alias sbd='sase -p bead'

With that alias loaded in a fresh shell, sbd sh<TAB> completes the static sase bead tree and dynamic bead-id slots still call the narrow candidate fast path. The print header is emitted only when a command actually runs, never during tab completion. It goes to stderr and omits the print switch, so stdout remains clean for JSON, generated scripts, and redirected command output.

Never eval "$(sase completion zsh)" in an rc file. That pays a full sase startup (300–640 ms) on every new shell; write the script to a file instead.

For zsh, the directory must be on fpath before compinit runs, or the completion silently never loads even though the file is sitting right there. Registration as _sase is necessary, but it is not the whole story: if another directory earlier on fpath also contains _sase, zsh can register the command and later autoload the older file. Frameworks such as oh-my-zsh may prepend their own completion directories after an earlier user prepend, so managed startup files should reassert the managed directory's priority after the framework loads. sase completion install prefers a directory your shell already scans — and, when you run a framework like oh-my-zsh, its completions drop-in directory, which the framework guarantees is on fpath before compinit. If it has to fall back to a conventional directory (~/.zfunc), it prints the exact line to add:

fpath=(~/.zfunc $fpath)   # must appear BEFORE compinit

sase completion install never edits ~/.zshrc, ~/.bashrc, or any other rc file — only the completion script itself.

What install Does

  1. Detects the shell from $SHELL and the parent process, unless one is given explicitly.
  2. Chooses a target directory, in order: -t/--target, then SASE_COMPLETION_DIR, then a shell framework's completions drop-in directory, then the first writable directory the shell actually scans, then a conventional fallback (~/.zfunc for zsh, ~/.local/share/bash-completion/completions for bash, ~/.config/fish/completions for fish). Two rules shape that middle ground:
  3. A framework's drop-in directory wins, and is created if the framework ships the fpath entry without the directory — on oh-my-zsh that is ~/.oh-my-zsh/custom/completions. It is the only scanned entry whose ordering is guaranteed, because the framework puts it on fpath itself, before it runs compinit. A plain user directory like ~/.zfunc is frequently appended after compinit, where a script in it never loads — and the fpath probe cannot tell the two apart, since it reads fpath once rc processing has already finished.
  4. Framework plugin directories and caches are never used, even though they are scanned and writable. An enabled plugin's own directory is on fpath only while that plugin is enabled, so a script dropped there hijacks an unrelated project's tree and disappears the day the plugin is turned off.

Among the remaining candidates, home directories win over system-wide ones.

  1. Warms and validates the runtime grammar cache for the selected shell. A cache hit verifies the cached file without importing the full parser; a miss generates the grammar once from the running CLI.
  2. Writes the loader atomically, and removes the script a previous install stamped somewhere else, so changing targets never leaves a second copy behind.
  3. zcompiles the zsh loader. The larger generated zsh grammar is compiled in the runtime cache when it is refreshed.
  4. Verifies registration for zsh by probing ${_comps[sase]} in a real, non-interactive shell. A file that exists but was written to a directory compinit never scanned is a silent no-op — install catches that and tells you exactly what to fix.
  5. Stamps ~/.sase/completion/stamp/<shell>.json with the sase version, the grammar structural digest, the target path, the loader representation, the loader digest, and an ownership marker (local or chezmoi), so sase completion list and sase doctor can tell a real install from a stray file.
  6. Reports every step's outcome and prints the recommended zstyle snippet below.

sase completion list (also the bare sase completion default; -j for JSON) shows every shell's generator availability, install status, owner, target path, .zwc freshness, and stamp version in one table. sase doctor includes the same checks as a non-blocking advisory group: completion.install runs by default, and completion.registration — which spawns a real shell — runs only under -D/--deep. File presence alone is never treated as evidence of a working install.

zsh's compsys is opt-in for grouping, descriptions, and menu selection. sase completion install prints this snippet at the end of a successful install; add it to your .zshrc after compinit:

# Recommended compsys styles for sase (grouped, described, menu-selected):
zstyle ':completion:*' menu select
zstyle ':completion:*' group-name ''
zstyle ':completion:*:descriptions' format '%F{yellow}-- %d --%f'
zstyle ':completion:*' verbose yes
zstyle ':completion:*' list-grouped true
zstyle ':completion:*' use-cache on

use-cache on matters beyond cosmetics: it is what lets the dynamic-value layer below actually cache results in-shell instead of re-forking sase on every keystroke.

Value Kinds And Caching

Options and positionals whose value can't be enumerated statically (bead ids, project names, repo names, plan references, and more) complete through sase completion candidates <KIND> [PREFIX] — a pre-argparse fast path in entry.py that never imports the full CLI, sase's TUI, or Rust extension surface it doesn't need, and answers in well under its latency budget for a warm process. KIND completes to the kinds this build can actually answer, so sase completion candidates <TAB> is the authoritative list; today that is agent, artifact, artifact_ref, artifact_relation, bead, directive, flag, memory, model, monitor, patch, pending_plan, plan, plugin, proc, project, project_tag, provider, repo, skill, snippet, tag, workspace, and macro. Path and directory slots are deliberately not kinds — the shell completes those natively.

Three flags matter when calling it by hand: -l/--limit N caps the printed candidates (default 200), -p/--project NAME scopes project-relative kinds to one project, and -S/--selector SELECTOR scopes plan_decision candidates to the one pending proposal that name, path, or notification ID (prefix) selects. Without -S, decision ids merge across every visible pending proposal; the generated shell helpers pass the PLAN already on the command line as -S when completing sase plan approve|reject -D.

Repo candidates come from SASE's read-only repo inventory, so they include primary, linked, sidecar, and external repository display names without cloning or resolving anything. Snippet candidates for sase snippet show and sase snippet delete come from the shared Rust editor snippet catalog and include generated aliases.

Memory selectors are a worked example of how a kind is shaped for a shell rather than for a report: sase memory read <TAB> and sase memory show <TAB> offer flat note names, bare web names, and web:slug strand references (glossary:agent-hood) read directly from the project's sase/memory/ tree, because sase memory read resolves a web:keyword reference case- and separator-insensitively and a slug is the one form that never needs quoting on a command line.

Pending plans work the same way: sase plan approve <TAB> and sase plan reject <TAB> offer only the names of plans actually awaiting approval (newest first, as tier · title · @agent · age), not the whole archived plan: catalog the plan kind covers. Freshness is a feature here — a plan you just approved must disappear, and one that just arrived must appear — so pending_plan candidates expire after 5 seconds in every shell (and in the on-disk cache) instead of the default 60.

That fast path is still a subprocess, so every generated script caches its output rather than calling it on every keystroke — necessary once something like zsh-autosuggestions' completion strategy is in the mix, which re-triggers completion on every character typed:

  • zsh caches through compsys's own _retrieve_cache/_store_cache machinery (subject to your use-cache on setting above), keyed per value kind.
  • bash caches into a declare -gA associative array scoped to the shell session.
  • fish forks a subshell for every (...) command substitution, so shell-side caching doesn't carry between keystrokes; it relies on the fast path's own on-disk cache instead (see SASE_COMPLETION_NO_CACHE below).

All three pass the full candidate set to the shell's own filtering rather than the typed prefix, so one cached fetch serves the whole word, not just one keystroke.

sase run's PROMPT argument is a special case: rather than a single value kind, it completes native file paths (for editor-drafted prompt files) and stored macro names together. Inside quoted or spaced prompt text, # completes macro names, % completes prompt directive names, @ completes canonical artifact references such as file:explicit:..., and + completes project tags such as +sase; the inserted value keeps the marker and only replaces the active embedded fragment. Project-tag candidates (sase completion candidates project_tag) are the enabled, launchable projects whose PROJECT_NAME fits the tag syntax, sorted case-insensitively; sibling records and the system-managed home project are left out, and, unlike the TUI and LSP + picker, Patches are not offered. They skip provider detection to stay on the fast path, so a tag for a project without a detected VCS provider is still offered and fails at launch with the usual error.

Environment Variables

Variable Effect
SASE_COMPLETION_CACHE_TTL Seconds an in-shell (zsh/bash) cached kind stays fresh. Default: 60.
SASE_COMPLETION_NO_CACHE Set to 1 to bypass the on-disk candidates cache entirely (fish, or debugging any shell).
SASE_COMPLETION_DIR Force sase completion install's target directory, overriding auto-detection.

Refresh Existing Installs

Refresh stamped installs without changing where they live:

sase completion refresh              # every stamped supported shell
sase completion refresh zsh          # one shell only
sase completion refresh bash -d      # show whether and why it would change
sase completion refresh -j           # machine-readable per-shell outcomes

The command validates or regenerates each runtime grammar cache, rewrites its stamped target to the loader representation, zcompiles zsh, and records a fresh stamp while preserving the existing owner (local or chezmoi). With no shell argument it refreshes every stamped zsh, bash, and fish install. Naming a shell with no stamp is a successful no-op (no stamped <shell> completion install), as is running without a shell when there are no stamps. Dry-run reports already current or would refresh with the detected drift reasons and touches no scripts, stamps, cache files, or zsh bytecode.

Refresh deliberately skips zsh's registration probe: that probe protects a first install, but can false-fail a refresh targeting a disposable or currently unregistered directory. Use sase doctor -D -C completion.registration when you want to test the active shell registration explicitly.

Legacy raw snapshots whose owner is chezmoi are refreshed in place to loaders, but the managed source still needs a one-time sase completion deploy-chezmoi migration so a later chezmoi apply cannot restore the frozen grammar. The explicit refresh command exits nonzero when any selected outcome cannot be refreshed. To convert a chezmoi-owned target to a local install, run sase completion install <shell> --force; without --force, install refuses the ownership change.

After every successful live sase update run, including an already-up-to-date no-op, SASE runs this same refresh automatically for all stamped installs. Dry-runs and --to mode switches do not refresh them. Refresh failures are displayed but do not fail the update itself.

Troubleshooting

Start with sase doctor — it runs the same install and registration checks described above:

sase doctor -v                    # human report, including completion checks
sase doctor -C completion.install
sase doctor -D -C completion.registration   # probes ${_comps[sase]} for real

completion.registration is a deep check: it runs only under -D/--deep, and selecting it without that flag is rejected with completion.registration selects deep checks only; rerun with -D/--deep to include them. A plain sase doctor therefore reports install status but does not probe registration.

Common issues:

  • Nothing completes in a new shell. Check sase completion list for the install status and target path. For zsh specifically, confirm the target directory appears in fpath before compinit — sase doctor -D -C completion.registration catches this even when the script file is present.
  • Registration says _sase, but completion still acts stale. The zsh registration table stores the function name, not the file that lazy autoload will choose. Run sase doctor -D -C completion.registration to detect an older _sase earlier on fpath; this commonly happens when oh-my-zsh prepends a custom completion directory after an earlier ~/.zfunc prepend.
  • Aliases like alias sbd='sase -p bead' stop completing static commands or options. A failure after -p or --print-command usually means zsh loaded an older grammar that does not know the root print-command option. Refresh the managed source, ensure the managed _sase wins on fpath, and open a fresh shell so the old autoloaded function is not reused.
  • An empty bead-id slot completes, but a typed prefix does not. Empty-slot success only proves candidates are being fetched. Typed-prefix matching also depends on the zsh completion environment around _describe; use a fresh shell with the repaired managed script before diagnosing candidate data.
  • Completion is slow. A cold first <TAB> for a zsh script that was written but never zcompiled costs 79–84 ms; re-run sase completion install to compile it. A slow dynamic value (a kinded slot) points at the candidates fast path itself — sase completion candidates <kind> directly to isolate it from shell overhead.
  • A completion looks stale. In-shell caches (zsh, bash) expire after SASE_COMPLETION_CACHE_TTL seconds (default 60); a fresh shell also reloads the installed loader and asks the active sase executable for current grammar. If the target is still a raw export or a pre-loader chezmoi source, run sase completion refresh for the applied target and sase completion deploy-chezmoi for managed sources, then open a fresh shell.

Measured Latency

Approximate figures, measured when this repo's live command tree had 331 parsers, 809 options, and 140 positionals (the tree has grown since, so treat them as a baseline rather than current numbers):

Stage zsh bash fish
Parse/load the generated script (per new shell) 0.4–12 ms (.zwc) / 79–84 ms (uncompiled) ~4–5 ms (uncompiled; no zcompile equivalent) 27.59 ms
Warm <TAB> (cached value kind) 0.4–12 ms ~9–10 ms 179.81 ms
Cold <TAB> (first fetch of a value kind) one sase completion candidates subprocess, ~65–90 ms ~65–90 ms 185.53 ms

zsh and bash numbers above are directly measured (zsh via the real-shell smoke tests under tests/completion/; bash via sourcing the generated script and timing _sase before and after its in-shell cache is populated). Fish was measured on athena with Debian fish, version 4.0.2 from package fish=4.0.2-1. The method generated the live fish script from this checkout, placed a temporary sase shim first on PATH, and used fish's time builtin for 30 samples of source sase.fish, cold complete -C 'sase run %mo' with a fresh SASE_HOME, and warm complete -C 'sase run %mo' after priming the on-disk candidate cache. Values in the table are medians; fish has no persistent in-shell cache, so warm still pays one sase candidate subprocess.

Plugin Commands

Top-level commands mounted by plugins through the sase_commands entry-point group (see plugins.md) are merged into the same runtime spec the generators above consume, so sase <name> <TAB>, sase completion spec, and the TUI : command line all complete plugin subtrees. The checked-in structural snapshot stays builtin-only; only the live runtime spec carries plugin children.

Freshness follows the same cache identity as everything else: the shell grammar cache and the TUI spec cache key on the plugin command set (names, owners, versions, locations, and the disable-switch state) plus the *.py sources of editable providers. A plugin install, uninstall, update, or editable source edit gives new shells and new TUI sessions a fresh grammar automatically. An already-running TUI rechecks the key off-thread each time the : command line opens, and reloads the grammar only when the key changed. Already-open shells keep their loaded functions until exec $SHELL; exported files from sase completion zsh > file stay unmanaged snapshots.

A plugin subtree that fails to load (or whose parser fails to build) is skipped and recorded once in the grammar manifest as an omission, so a broken plugin is not re-imported on every new shell. sase doctor reports omissions as WARN in the completion.plugins check with a sase plugin update <name> next step.

Plugin authors opt argument slots into path completion with a public string attribute on the argparse action: action.sase_completion = "path" (or "dir"). Otherwise only argparse choices produce candidates — none of sase's builtin name, override, or hint heuristics apply inside a plugin subtree, and no default_child is inferred.

Deferred

Recorded here rather than shipped in this epic — cheap to add later once the spec model exists, and not blocking the core experience:

  • A carapace-spec emitter from the same CompletionSpec model, for nushell, elvish, and PowerShell. SASE targets POSIX hosts today.