Design spec
Status: living document, design phase. Decisions recorded as made.
1. Purpose & Position#
Shell completion for binaries built on sxcli.dev/fw. Deliberately a
separate module — never part of the core — for two reasons:
- it hammers home that the core stays simple: completion is an ordinary ecosystem package with no privileged access;
- it is the first external consumer of the core's Introspector API — any capability gap it hits is fixed as a core API improvement, never a backdoor.
The core prerequisites landed in fw (spec §4/§5, commit d28173e):
Hidden()/System() registration options; System applets are
selectable by an explicit first token in every dispatch mode (including
single-applet mode), invisible in listings/basename dispatch, and
excluded from single-applet counting.
2. Shape#
One applet per shell, each in its own package, selected by blank import exactly like the fw log sinks — a binary links only the shells it supports:
import ( _ "sxcli.dev/completion/bash" _ "sxcli.dev/completion/zsh" _ "sxcli.dev/completion/fish" )
Each package registers one applet, id completionbash /
completionzsh / completionfish (plain lowercase — conforms to the
fw service-id rule; these are System applets, humans never type them).
The applets are thin adapters. All candidate computation lives
once, in internal/engine; a shell package owns exactly two things:
- its registration script template (bash
complete -F, zshcompdef, fishcomplete -c— genuinely different syntax), and - its answer encoding (bash: bare newline-separated words; zsh and fish carry per-candidate descriptions, sourced from the fw usage/Doc metadata).
internal/engine starts internal but its API is designed as if
public — it may be promoted to an exported package when a third-party
shell package (powershell, elvish, …) materializes; foreign modules
cannot reach another module's internal/.
REVERSED 2026-07-14, before first publishing: engine and script
are PUBLIC packages (sxcli.dev/completion/engine,
sxcli.dev/completion/script). Rationale: the fish adapter was
dropped from the roadmap (Plamen: not a shell he wants to support;
community territory) — but "the community can build fish" is only
true if the engine is reachable, and internal/ would have blocked
third-party adapters until a breaking reshuffle. Published public
from day one instead; the as-if-public discipline made the move a
git mv. The layering rule is unchanged: the engine still does not
know scripts exist — script (generation policy) is public alongside
it so dispatch truth is never reimplemented per shell.
3. Applet contract (to be detailed)#
Two operations per shell applet:
- script emission — print the shell registration script to stdout (sourced/eval-ed by the user's shell setup);
- query answering — given the target applet and the words before the cursor, print the candidates in the shell's encoding.
The generated script invokes <binary> completion<shell> … — an
explicit first-token System selector, valid in every fw dispatch mode.
4. Engine API (decided 2026-07-13; implementation pending)#
internal/engine, see engine.go. Decisions:
- Single entry point —
Complete(src, q) []Candidateanswers everything: applet-name completion, argument names, declared value domains, file/directory directives. The adapter never decides what is being completed, only how to print it. Sourceis a locally-defined interface (Applets,Arguments) — the honest ledger of what the module needs from the core;*sxclifw.Introspectorsatisfies it implicitly, tests use fakes.Query{Applet, Words, Current}— the words-before-cursor contract of the core Introspector is baked into the type system: the half-typed token has its own field (Current), used only as a filter prefix, never planned.Kinddirectives, declared not guessed —KindFiles/KindDirsare emitted only for fields with a declaredHintFile/HintDirectory(fwFieldMetadata.Hint, landed fw@4b7edd4 with the core's own--configdeclaringHintFile). Undeclared plain string values yield no candidates; the generated bash script usescomplete -o defaultso the shell's own file completion is the natural fallback.- No error return — best-effort like the Introspector itself: a shell script cannot render an error; an unanswerable query yields no candidates. (Confirmed 2026-07-13.)
- Service-id completion — fields declared
HintServiceID(fw d7232f2; the core's--disable/--enabledeclare it,--overridedoes not —from=topairs fit no honest hint) complete asKindValuecandidates drawn fromSource.Services().
Dependency note: requires the fw hint API — unreleased at the time of
writing; go.mod carries replace sxcli.dev/fw => ../sxcli-fw until
fw v0.1.1 is tagged, when the replace is dropped.
5. Engine implementation (decided + landed 2026-07-13)#
Complete resolves the target like core dispatch (explicit q.Applet
→ SingleApplet() — a core Introspector method added for exactly this,
fw d302f36, because Applets() is public-only while a Hidden
non-System applet still counts for the mode → bare first word as
selector; no target + no words = completing the first word: public
applet names). It then plans the schema via Arguments(target, words)
and replays the words exactly as the parser would: -- puts the
cursor in positional land (silent), a non-bool long or final bundled
short with no =-joined value leaves a pending value, consumed longs
are recorded. Emission rules:
- pending value →
Alloweddomain, else booltrue/false(reachable only through the=form — bools never consume the next word, mirroring the parser), else the hint (KindFiles/KindDirsdirectives;HintServiceID→Services()), else nothing (shell default). Currentof the form--name=prefix→ the semantic=split is the engine's (parser semantics); adapters only reassemble shell-mangled tokens. Value candidates are returned bare.Currentstarting with-→ long argument names only (shorts are for people who know what they are doing), used scalars suppressed, used slices still offered (repetition is their append mechanism),Docfalling back to the usage one-liner.- fresh bare word → silent; the shell's own default (files) is the honest fallback.
Unit-tested in z_engine_test.go against a fake Source.
6. Bash adapter (landed 2026-07-13)#
Wire protocol — raw transport, smart Go; the script is deliberately dumb and never needs to change:
<cmd> completionbash [--applet <id>] --cword $COMP_CWORD -- "${COMP_WORDS[@]}"
Raw COMP_WORDS as positionals (command word included, =-splits
unrepaired), COMP_CWORD locating the cursor. The Go side drops word
zero, reassembles the =-splits (bash's COMP_WORDBREAKS tears
--debug=fal into three words; value candidates return bare because
bash replaces only the post-= word), slices at the cursor and calls
the engine. All fields env:"-": per-keystroke transport, not
configuration. Queries always exit 0.
--script generation happens THROUGH the name it serves — the
basename decision is made once, at generation, never per keystroke:
single-applet mode → nothing baked (any name runs the sole applet);
basename names a public applet (busybox symlink farm) → --applet
baked, mirroring dispatch rule 4; anything else — the real binary name
included — keeps selector logic live, so mybin cat /tmp/z<TAB>
completes via the engine's bare-first-word rule and an explicitly
typed Hidden id still completes its arguments while never being
offered by name. Note: generation cannot distinguish "real binary
name" from "symlink to a hidden applet" (Applets is public-only, by
design) and does not need to — selector-mode registration is exactly
what dispatch honors for both, so the refusal case from the earlier
draft dissolved. Installation: eval "$(mybin completionbash --script)" per name, one eval per symlink actually created — a
single blanket registration for every applet id would hijack real
commands' completions (cat!).
Answer encoding: one candidate per line on stdout; KindFiles/KindDirs
arrive as \001-sentinel lines the script maps to compgen -f/-d;
complete -o default keeps undeclared values on the shell's own file
completion. Unit-tested in z_bash_test.go (fake Source: reassembly,
baked/selector queries, directives, script golden fragments);
end-to-end smoke verified against a real fw binary.
Colon/equals handling (2026-07-13): bash splits COMP_WORDS at every
COMP_WORDBREAKS character — ":" and "=" included — so true tokens like
unix:/dev/log arrive shredded, and COMP_WORDS alone cannot tell
--addr:8080 from --addr :8080. The protocol therefore carries
--line "$COMP_LINE" and --breaks "$COMP_WORDBREAKS", and
reassemble is a faithful Go port of bash-completion's
_comp__reassemble_words: the original line's whitespace decides
glued-vs-spaced separators, separators never join word 0, and only
characters actually present in the user's break set are excluded (a
shell with ":" stripped from COMP_WORDBREAKS — the documented
workaround — passes words through untouched). On output, bash replaces
only the segment after the last break it split on, so answer prints
candidates segment-relative (the __ltrim_colon_completions
treatment, generalized): unix:<TAB> answers /dev/log, not
unix:/dev/log, and a shell that does NOT break on "=" gets the full
--name=value token rebuilt. Assumption: breaks default to bash's
stock set when a query arrives without --breaks (manual invocation).
7. Zsh adapter (landed 2026-07-14)#
Same shape as bash over the same engine; the shell changes four things. Transport is SIMPLER — zsh tokenizes properly (no COMP_WORDBREAKS shredding, quotes honored), so there is no line, no breaks, no reassembly, and no output segmenting:
<cmd> completionzsh [--applet <id>] --cword $((CURRENT-1)) \
--current "$PREFIX" -- "${(@)words}"
(mybin completionzsh --script)"`, requires compinit.
The --script baking decision (single-applet / busybox symlink /
selector mode) moved to a shared package, internal/script, used by
both adapters — the engine stays ignorant that script generation
exists (Plamen's call: layering, engine = "what completes here" only).
Unit tests in z_zsh_test.go; smoke-verified under a real zsh with
stubbed _describe/_files/_default sourcing the generated script.
8. Open questions (next discussion targets)#
- fish adapter (same shape; native descriptions,
complete -c). - Integration (x_) tests: bash AND zsh DONE (x_bash_test.go + x_zsh_test.go, 2026-07-14 — the test binary re-execs itself as a real fw application via fw's personality pattern; six wire-level query assertions each, plus one test per shell sourcing the generated script under the real shell with stubbed completion machinery — bash reads COMPREPLY, zsh reads the _describe pairs; skipped cleanly when the shell is absent).
- Later: completing
--overridevalues (understanding thefrom=topair form is engine-side knowledge, not a field hint).