sugarcraft / candy-scriptui
Interactive terminal widgets for shell scripts — composer-installable CLI of SugarCraft TUI primitives, modeled on charmbracelet/gum. 13 subcommands: choose, confirm, file, filter, format, input, join, log, pager, spin, style, table, write.
Requires
- php: ^8.3
- react/event-loop: ^1.6
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-forms: dev-master
- sugarcraft/sugar-fuzzy: dev-master
- sugarcraft/sugar-markdown: dev-master
- sugarcraft/sugar-pty: dev-master
- sugarcraft/sugar-style: dev-master
- symfony/console: ^6.4 || ^7.0
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:01:03 UTC
README
CandyScriptui
Formerly published as
sugarcraft/candy-shell(renamed 2026-10). Namespace changed:SugarCraft\Shell\→SugarCraft\ScriptUi\.
composer require sugarcraft/candy-scriptui
candy-scriptui — a composer-installable CLI of SugarCraft TUI primitives for
shell scripts, for PHP 8.3+. Composer installs the launcher at
vendor/bin/candy-scriptui; the examples below assume it is on your PATH
(add vendor/bin to it, or invoke the full path). Prebuilt single-file
binaries are published per commit as rolling dev phars:
curl -fsSL -o candy-scriptui \ https://github.com/detain/sugarcraft/releases/download/candy-scriptui/dev/candy-scriptui.phar chmod +x candy-scriptui
# Apply styling. candy-scriptui style --foreground "#ff5f87" --bold "Hello, candy!" # Pick one item. choice=$(candy-scriptui choose Pizza Burger Salad) # Read a single line. name=$(candy-scriptui input --placeholder "Your name?") # Confirm a destructive action. candy-scriptui confirm "Really delete $file?" && rm "$file"
Subcommands
All 13 presentation subcommands ship, plus completion — 14 in the table
below. Run candy-scriptui <cmd> --help for the full
flag list per command.
Auto-discovery
CandyScriptui uses PHP attributes to auto-discover commands at runtime.
Mark any class extending Symfony\Component\Console\Command\Command with
#[Command] and it will be picked up by Application::scan().
Help attributes
Two additional class-level attributes enrich the --help output of
your commands (attach them to the command class — method placement
registers nothing):
#[Alias('name')]— registers an alternative name for the command (e.g.choose→cho) when the command is discovered throughApplication::scan(). Multiple aliases are supported via repeated attributes.#[Example('usage', 'description')]— adds an example line to the command's help block. Thedescriptionparameter is optional. Multiple examples are supported via repeated attributes.
The HelpFormatter renders these automatically: when help is requested
for a command that carries them — via --help, -h, or the gum-compat
--show-help — their block is printed ahead of Symfony's standard help.
Commands without the attributes get the stock help verbatim.
Typo suggestion
When a user types an unknown command name, Application::find() refuses it
outright — commands are only ever matched by exact name, never by prefix —
after running the name through a TypoSuggester: one near alternative is
quoted back (Did you mean "choose"?), several are listed (Did you mean one of: "completion", "confirm", "log"?), and beyond distance 2 nothing is
suggested.
Output channels
Every subcommand keeps two streams apart. Data (the styled text, the
chosen value, the rendered table, the completion script) goes to STDOUT
raw — never through the console formatter, so a value that happens to look
like <info> survives byte-exact. Diagnostics (refusals, malformed
flag values, I/O failures) go to STDERR as plain sentences, so scripts
that eval completion output or capture confirm answers can never
consume an error message as data.
use SugarCraft\ScriptUi\Attribute\Flag; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; // Fully-qualified: the unaliased `Command` name already belongs to the // Symfony base class. #[\SugarCraft\ScriptUi\Attribute\Command(name: 'mycmd', description: 'Does something useful.', descriptionSection: 'Longer help text.')] #[Flag(name: 'format', short: 'f', description: 'Output format.', enum: FormatType::class)] final class MyCommand extends Command { protected function configure(): void { } protected function execute(InputInterface $input, OutputInterface $output): int { $format = $input->getOption('format'); // Scans into a ScriptUi application enforce `enum:` before the // command runs (invalid values exit 2); custom runners such as // CommandTester validate the value themselves via ValueEnum::validate(). // ... return self::SUCCESS; } } enum FormatType: string { case Json = 'json'; case Yaml = 'yaml'; }
#[Flag] sits on the class (the scanner reads class attributes only);
repeat it once per option.
Register the namespace in your bootstrap:
$app = new \SugarCraft\ScriptUi\Application(); $app->scan('My\\Namespace\\'); // discovers all #[Command] classes $app->run();
Application::scan($namespace) iterates all already-loaded classes
under that namespace, picks those bearing #[Command], and registers
them into the application. Classes must be autoloaded (or required)
before scan() can find them — the scanner uses get_declared_classes()
and does not trigger autoloading.
| Command | Role |
|---|---|
choose |
Pick one or many items from a list. |
completion |
Emit shell completion script (bash · zsh · fish). |
confirm |
Yes/no prompt — exit 0 on confirm, 1 on cancel. |
file |
Interactive file picker. |
filter |
Incremental filter over stdin lines (substring default, --fuzzy opt-in). |
format |
Render Markdown / code / template / emoji to the terminal. |
input |
Read a single line; supports --password masking. |
join |
Join N strings (or stdin lines) with a separator — raw text, not styled fragments. |
log |
Levelled / structured log output (text · json · logfmt). |
pager |
Scrollable viewer for long input. |
spin |
Run an external command behind a spinner. |
style |
Apply ANSI styling to argv (or stdin). |
table |
Render a CSV / TSV table. |
write |
Multi-line text editor. |
Flag reference (selected highlights)
Some gum flags exist here under different spellings and a few niche knobs
were dropped rather than shipped as silent no-ops: every flag listed by
--help is live. The shipped surface covers the 80 % case for shell
scripts. Common
flags across commands:
--limit N/--no-limit/--selected="a,b"— multi-select onchooseandfilter;--selectedis repeatable and comma-separated lists are split into separate items (previously the whole string named ONE item, silently selecting nothing).--ordered— keep selection order instead of click order onchooseonly (filterhas no such flag).--strict/--fuzzy/--no-fuzzy— matching mode onfilter. Substring matching is the default; fuzzy Smith-Waterman is opt-in.--header "Email:"/--prompt "> "/--value "$LAST"/--char-limit N/--width Noninputandwrite;--max-lines Nand--show-line-numbersexist onwriteonly.--affirmative "Yes"/--negative "No"/--default=yes|no/--show-outputonconfirm. An unknown--defaultword is refused (exit1) rather than silently treated as "no".--show-output/--show-errorplus 12 spinner styles (dot·line·pulse·globe·points·monkey·moon·meter·mini-dot·hamburger·ellipsis·jump) onspin.--min-level info/--prefix/--time RFC3339/--file out.log/--formatter text|json|logfmt/--structuredonlog. Unknown--formatternames are refused (exit1).--border/--border-foreground "#ff0"/--height N/--trimonstyle.- Colours on
--foreground/--background/--border-foregroundaccept the 16 palette names (red,bright-blue, …),#rgb/#rrggbb(bare hex too), or a palette index0-255.
Environment
CandyScriptui respects the standard CLI colour conventions. Resolution
lives in Runtime/ColourGate.php, which pins sugar-style's ambient
colour profile before each command runs, so every styled byte stream —
flag-built style output, log badges, and the format markdown
theme — renders inside the resolved capability. The tiers are
sugar-core's ColorProfile semantics: Ascii drops colour but keeps
decoration, NoTty emits no escapes at all, and Ansi/Ansi256
re-quantise colours to that depth.
--no-ansi(global flag) emits plain bytes — every SGR escape dropped, decoration included.--ansioverrides.NO_COLOR(any truthy value) disables colour; decoration (bold, underline, …) and layout survive.CLICOLOR=0disables colour when stdout is not a TTY (otherwise defaults to colour).CLICOLOR_FORCE=1keeps full colour even when stdout is piped or redirected, winning overNO_COLOR.FORCE_COLOR=1|2|3forces a specific tier (16 / 256 / TrueColor) and wins over every other env rule.TERM=dumbemits plain bytes, like--no-ansi.
Being a pipe does not by itself strip colour — styled bytes flow to
redirected output as always; set CLICOLOR=0 for pipe-aware muting.
CANDY_SCRIPTUI_* env var fallbacks
Only presentation options may be given via an environment variable, keyed
by the CANDY_SCRIPTUI_ prefix followed by the option name in uppercase
with non-alphanumeric characters replaced by _. The permitted names are
align, background, bold, border, border-foreground,
cursor-prefix, faint, foreground, height, italic, prefix,
reverse, separator, strikethrough, timeout, underline,
unselected-prefix, verbose, width. For example:
# Equivalent to: candy-scriptui style --foreground=#ff0000 --bold "Hello" CANDY_SCRIPTUI_FOREGROUND=#ff0000 CANDY_SCRIPTUI_BOLD=1 candy-scriptui style "Hello"
Everything else is CLI-only by construction: options that answer for
the user (--default, --selected, --value), grant capability
(format --allow-env), or redirect data (--file, --out) are never
read from the environment — a stray CANDY_SCRIPTUI_* in a shared CI
image cannot silently weaken a fail-closed flag. Names outside the list
are ignored with no notice.
The pre-rename CANDYSHELL_<NAME> spellings still work for the same
allow-listed names: the launcher mirrors them onto CANDY_SCRIPTUI_<NAME>
with a deprecation notice, and the new name wins if both are set.
The fallback is applied when no explicit CLI option is provided. An
explicit flag on the command line always takes precedence over the env var.
Negatable options (--no-<name>, InputOption::VALUE_NEGATABLE) are not
sourced from the environment — the fallback skips them (the shipped
command surface currently defines none).
Shell completion
candy-scriptui completion --shell=bash candy-scriptui completion --shell=zsh candy-scriptui completion --shell=fish
Emit a shell completion script for bash, zsh, or fish. Source the output directly or drop it into the appropriate completion directory.
Version
candy-scriptui --version answers through Application::versionString(), in
precedence order:
- When running from a phar, the version stamped into the archive's phar
metadata at build time (a
dev-buildworking-tree stamp is refused, so a dev archive never impersonates a release). - Otherwise the nearest reachable
composer.jsonthat carries a non-emptyversionfield, found by walking up from the package directory. - Otherwise
0.0.0— the honest answer when no version metadata is reachable. Acomposer require-installed copy inside a project whose rootcomposer.jsondeclares noversionsees exactly this; use the phar (or the versioned release) when the stamp matters.
Language
candy-scriptui reads LC_ALL, then LC_MESSAGES, then LANG at startup
and answers refusal and UI messages in that language. Sixteen catalogs ship
with the package; a territory spelling falls back to its base language
(de_DE.UTF-8 → de) and then to English. C/POSIX stay English.
Exit codes
0— normal completion. Forconfirm, this means the user picked the affirmative answer.1— generic failure: an unknown command name,confirmdeclined, an abort (Esc) fromchoose/filter/input/write/file, a refused flag value (--default,--formatter,--shell,--cursor-mode), alog --level fatalline, or exit code1forwarded from the external command run byspin.2—confirmaborted (Esc) — distinct from a "no" answer.124— a--timeoutdeadline elapsed before the interaction finished (gum-compatible, matches GNUtimeout(1)). A malformed--timeoutvalue is refused up front (exit1on STDERR) — it is never coerced to "no deadline". Forspin, the captured child output is still flushed when--show-output/--show-errorare set, so a timed-out build or test run shows what it managed to print.130— cancelled with Ctrl+C, whether typed (raw mode reports it as a key, and the models record it separately from Esc) or delivered as SIGINT (128 + 2).spinlikewise exits130when its child is interrupted with Esc and never reports a completion code.143— terminated by SIGTERM (128 + 15). Other OS signals follow the same128 + signumconvention.
spin forwards the external command's own exit code verbatim (gum parity),
which means a child exiting 2, 124, 130, or 143 is indistinguishable
from candy-scriptui's own codes for those numbers; gate on the child's
success (0) rather than specific non-zero values.
Keyboard map (interactive commands)
| Key | Effect |
|---|---|
| ↑ / ↓ | Move the highlighted item / cursor line. |
| Enter | Submit choose, confirm, filter, input (in write, inserts a newline). |
| y / n | Answer confirm directly. |
| Ctrl+D | Submit write (end-of-input convention). |
| Space | Toggle selection in multi-select choose / filter; page down in pager. |
| Tab | Toggle the highlighted item in multi-select filter. |
| Any printable key | Filter text in filter. |
j/k PgUp/PgDn b/f g/G Home/End Ctrl+U/Ctrl+D q |
Paging, scrolling, top/bottom jumps, and quit in pager. |
| Esc | Abort (exit 1, or 2 on confirm). |
| Ctrl+C | Abort with exit 130; spin also terminates its child. |
| Backspace | Ascend a directory in file; delete before the caret elsewhere. |
The key bindings are owned by each Model; the table matches their
doc-comments, and those are exercised by the model tests.
Sizing, hints, and terminal modes
- Interactive lists and the pager size themselves from the terminal at
launch (COLUMNS/LINES, then an ioctl against the controlling stream).
An explicit
--width/--heightwins; when nothing is knowable the historical literals (lists 60 cells, pager 80×20) keep piped output byte-stable.choosegained--width;filter's existing--widthis now honoured;pager's--width/--heightdefault to0= auto. pagerre-fits live on resize. The list views do not re-flow live (sugar-formsItemListexposes resize, but--widthis a launch-time cap there — following SIGWINCH would silently widen explicitly-sized lists; see the sugar-forms seam note below).choose,filter, andpagerpaint a one-line keybinding hint under the live view (removed from the resolved/final frame).choose/filter/pager/write/fileaccept--no-alt-screento render inline. Note the flip side: entering the alternate screen (the default) ERASES the rendered frames on exit — pass--no-alt-screenwhen you want the interaction to remain in scrollback.- On
TERM=dumbthe runtime forces inline mode, keeps the cursor visible, andColourGatelowers colour — the DEC-private modes the renderer would emit are exactly what such terminals cannot digest.
Compatibility with gum invocations
Most gum X invocations work as candy-scriptui X verbatim. Known
behavioural differences:
formataccepts-t/--type(markdown,code,template,emoji) alongside--theme. Template support is the lightweight{{VAR}}expansion — Go template-function helpers are not implemented. Expansion is fail-closed: only variables named in--allow-env=A,Bare read from the environment, and a placeholder that resolves to nothing is announced on STDERR instead of vanishing silently.--timeout,--show-help, and--strip-ansiwork on the commands that accept them:--timeoutarms a real deadline (exit124) on every interactive command includingfile,--show-helpis honoured as an alias for--helpon every command that defines it, and--strip-ansiis read byinput,format, andstyle. The--cursor-modeflag is honoured byinputandwrite(hiddenhides the cursor;blinkandstaticleave the terminal default) and refuses any other value. Where a flag is meaningless to a non-interactive command (format,join,log,style,table) it is still accepted but treated as a no-op.confirm --default=yes|nois the form to use; the older--default-yesalias is preserved.
Testing
cd candy-scriptui && composer install && vendor/bin/phpunit
Runnable examples live in examples/:
examples/cli.php— drive theApplicationin-process (register commands, run, read exit codes).examples/setup-script.sh— a shell script gating oncandy-scriptui confirm's exit code.
Demos
choose
confirm
file
filter
format
input
join
log
pager
spin
style
table
write
Shared foundations
The filter command uses sugar-fuzzy for
opt-in fuzzy-matching (--fuzzy; the default is substring) via
FilterModel → SmithWatermanMatcher. The
SubStyleParser utility parses gum-style element.prop=value entries
into per-element Style maps for extension commands; no built-in
command consumes it today, and choose's --cursor/--header flags are
plain strings, not sub-style entries.
Internals
spin runs its child through the RealProcess wrapper, which is
@deprecated in favour of sugar-pty's PosixProcess; SpinCommand is
currently its only consumer and the wrapper is kept for that path until
the port lands.
Related
- SugarCraft monorepo
- Originally inspired by charmbracelet/gum
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.












