Implemented. See src/eosh/completion.py (ArgcompleteCompleter) and tests/test_argcomplete_fallback.py.
argcomplete is the de-facto completion library for Python CLIs that use argparse. Tools that opt in include:
pipx, conda, pre-commit, tox, pdm, httpie, nox, virtualenv, …argcomplete.autocomplete(parser) into their entry point.ArgcompleteCompleter drives the protocol directly so eosh can complete these tools without any per-command recipe and without depending on the bash-completion package.
Combined with cobra.md (Go CLIs, opt-in by name) and recipes/aws.py (AWS CLI v2’s aws_completer), three protocol fallbacks cover the vast majority of modern CLI tools out of the box.
@registry.command completer produced candidates — recipes always win.FileCompleter.A Python script registers completion via:
#!/usr/bin/env python3
# PYTHON_ARGCOMPLETE_OK
import argparse, argcomplete
parser = argparse.ArgumentParser()
# ... add arguments ...
argcomplete.autocomplete(parser)
parser.parse_args()
The # PYTHON_ARGCOMPLETE_OK comment is the marker that completion-driving code looks for. When argcomplete.autocomplete() runs and sees _ARGCOMPLETE=1 in the environment, it computes candidates from the parser, writes them to fd 8 joined by the value of _ARGCOMPLETE_IFS (default \v), and exits without running the user’s code.
| Variable / fd | Meaning |
|---|---|
_ARGCOMPLETE=1 |
enable completion mode (value is also a 1-based “leading words to skip” count; we always pass 1) |
_ARGCOMPLETE_IFS |
candidate separator (default \v) |
_ARGCOMPLETE_SHELL |
bash/zsh/fish — affects formatting; we use bash |
_ARGCOMPLETE_SUPPRESS_SPACE |
1 to suppress trailing space |
COMP_LINE |
the entire command line up to cursor |
COMP_POINT |
byte offset of the cursor within COMP_LINE |
| fd 8 (output) | candidates joined by _ARGCOMPLETE_IFS |
| fd 9 (debug) | argcomplete’s debug stream (we discard it) |
Detection inspects the executable file (without running it), caching the result per command per shell session. Three cases:
Plain Python script with marker. The script begins with #!/usr/bin/env python3 (or similar) and contains # PYTHON_ARGCOMPLETE_OK in its first 2 KiB. Cheapest case — no extra subprocess.
Setuptools console_scripts shim. The shim is a small Python file generated at install time:
#!/path/to/python
import sys
from MODULE.submodule import FUNC
if __name__ == '__main__':
sys.exit(FUNC())
The shim itself never has the marker. We parse the from … import … line, then run a tiny probe with the shim’s own Python interpreter to locate the imported module via importlib.util.find_spec and read its first 1 KiB for the marker. Using the script’s interpreter ensures the right sys.path for the venv.
Anything else. Compiled binaries, shell scripts, missing files — return False without invoking anything.
The probe never executes user code; it only parses the shim and reads bytes.
After detection succeeds, completion is driven by:
env = os.environ + {
"_ARGCOMPLETE": "1",
"_ARGCOMPLETE_IFS": "\v",
"_ARGCOMPLETE_SHELL": "bash",
"_ARGCOMPLETE_SUPPRESS_SPACE": "1",
"COMP_LINE": <full line>,
"COMP_POINT": <byte offset of cursor>,
"COMP_TYPE": "9", # 9 = TAB
}
# child gets a pipe write end on fd 8
subprocess.Popen([command], env=env, pass_fds=(8,),
stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL)
# read candidates from the pipe read end, split on \v
The fd-8 plumbing has a subtle constraint: pass_fds only works for fds that are open in the parent at the time of Popen. We dup2 the pipe’s write end onto parent fd 8, then list 8 in pass_fds, then restore parent fd 8 after. This ensures the child inherits fd 8 pointing to our pipe.
1. Recipe / @registry.command completer for this position? → use it
(cobra tools are recipes too — their delegate is CobraCompleter)
2. None-key OptionsCompleter and prefix starts with "-"? → use it
3. No completer registered at this slot, and the command is
an argcomplete-marked Python script? → try it
- non-empty result → use it
- empty result or unavailable → fall through
4. FileCompleter fallback (no completer registered) → use it
A registered completer that returns [] ends the chain. It meant “nothing here”, not “ask someone else”.
from eosh.completion import (
enable_argcomplete_fallback,
disable_argcomplete_fallback,
get_argcomplete_fallback,
ArgcompleteCompleter,
)
# Default: enabled. To override the timeout (default 2.0s):
enable_argcomplete_fallback(timeout=5.0)
# To turn it off entirely (e.g. in ~/.eosh/config.py):
disable_argcomplete_fallback()
| Aspect | Assessment |
|---|---|
| Latency (probe) | First TAB on a command runs a small subprocess (the probe), typically 30–80 ms. Cached per command for the rest of the session. |
| Latency (invoke) | One subprocess per TAB; argcomplete-instrumented Python tools usually return in 50–300 ms. Capped by timeout (default 2.0 s). |
| Caching | Detection is cached per command for the session. Results go through completion_cache, keyed on cwd and line, and are cleared after every command. |
| Correctness | argcomplete parses COMP_LINE/COMP_POINT itself — quoting/escaping match user expectation. |
| UX gap vs. recipes | Plain string candidates (no description, no value placeholder on flags). Recipes remain the path for richer UX. |
| Failure modes | Tool missing, isn’t argcomplete-aware, errors, or times out → empty result, fall through to FileCompleter. Never crashes the prompt. Never invokes a non-argcomplete tool blindly. |
| Security | Detection only reads bytes from disk; never executes user code. Invocation only runs tools already marked as argcomplete-aware. |
Naively running <command> with _ARGCOMPLETE=1 would be unsafe. Tools that don’t read the env var simply ignore it and run normally. So _ARGCOMPLETE=1 rm -rf /tmp/foo would actually delete files. That’s why detection inspects the file’s bytes and only invokes once the marker is confirmed.
name<tab>description pairs via _ARGCOMPLETE_DFS. Plumbing those through to Completion(description=...) would match the cobra completer’s UX._ARGCOMPLETE_SUPPRESS_SPACE results. Currently we always insert a trailing space; some completions (file paths with /) want to suppress it.