CCFE is a small Perl/curses tool that puts an interactive, screen-oriented front-end on command-line scripts and commands: it prompts for the information a command needs and can be driven by your shell to provide predefined selections and run-time defaults.
Menus, forms and the commands they run are described in plain declarative text files, so you can build a whole guided interface — and ship it as a plugin — without writing any Perl.
The program source lives under src/; a mirror of the original
project website and its documentation is under src/website-mirror/, and
the manual pages are under src/man/.
src/ccfe.pl is the program (currently version 2.4). The planning
documents of the modernisation — packaging, security hardening, optional colour
and a move to a modular, more functional structure — are kept in
docs/history/. CCFE depends on standard packages only
(Perl core plus libcurses-perl); the git history and its tags detail past
fixes.
Recent work added things you drive from inside the running UI: a guided
config wizard, runtime config reload, colour-theme and key-map switching
(including Meta/Alt and Ctrl bindings), menu/form search, file-based
notifications, object-ownership management, and a one-step "run CCFE at login"
kiosk setup. These are covered below and in man ccfe.conf.
v2.0 reorganises the install layout (menus/forms moved out of lib/ into
share/ccfe/objects/; per-user files follow the XDG base directories). See
MIGRATION.md if you are upgrading from a 1.x install.
perl— core modules only (POSIX, IPC::Open3, Getopt::Std, Text::Balanced, File::Temp, …).libcurses-perl— theCurses/ ncurses + form + menu bindings.
On Debian/Ubuntu:
sudo apt-get install perl libcurses-perlFrom source. The installer copies the program unmodified (it resolves its
paths at runtime from its own location) and installs the bundled sysmon
sample plugin into the demo menu:
cd src
sh install.sh # interactive; or:
sh install.sh -b -p "$HOME/.ccfe" # batch install under ~/.ccfeAdd the install's bin/ to your PATH (e.g. export PATH="$HOME/.ccfe/bin:$PATH")
so you can just type ccfe. The tree under the prefix holds bin/, etc/,
msg/, man/, doc/ and share/ccfe/{objects,themes,keymaps}.
From a package. The .deb, the .rpm, the Alpine package and the OpenWrt
package (recipes in packaging/, built packages in dist/) all stage the same
tree under /usr/lib/ccfe and link ccfe, ccfe-build, ccfe-gen and
ccfe-login into /usr/bin and the man pages into the man path. The system
configuration is a real config file, /etc/ccfe/ccfe.conf, which upgrades leave
alone (/usr/lib/ccfe/etc/ccfe.conf is a link to it); the system menus and
forms are in /usr/lib/ccfe/share/ccfe/objects/.
sudo apt install ./dist/ccfe_<version>_all.deb # Debian, UbuntuCCFE takes an optional shortcut naming the menu or form to open. With no argument it opens the program's default menu.
ccfe # default menu
ccfe demo # the demo menu
ccfe sysmon # the bundled sample-plugin menuA shortcut can name a form as well as a menu, including one inside a
.d/ form directory — handy for jumping straight to a single data-entry
screen or wiring CCFE into another script:
ccfe demo.d/recursive # open a form directly
ccfe sysmon.d/sar # the sysmon "system activity report" formCCFE resolves a shortcut <name> by looking for <name>.menu then
<name>.form along its search path (see Plugins below). Run ccfe with no
argument, or consult man ccfe, to see what is available in an installation.
CCFE keys its menu search path and config file off the name it is invoked
as (basename($0)). Symlink the binary to a new name and it becomes a
self-contained front-end with its own menus and its own <name>.conf:
ln -s "$HOME/.ccfe/bin/ccfe" "$HOME/.ccfe/bin/ops"
mkdir -p "$HOME/.local/share/ccfe/ops" # drop ops's *.menu / *.form here
ops # runs the "ops" tree, reads ops.confThis makes CCFE a natural way to give a team (or a constrained login) a
purpose-built menu without touching the default ccfe one.
An instance opens <name>.menu (or <name>.form) from its object path when
there is one, and ccfe.menu otherwise. Its messages come from the default
set unless a msg/<lang>/<name> file exists. theme = NAME in an instance's
config finds <name>.conf.NAME in the themes directory first and then the
shipped ccfe.conf.NAME, so an instance can use smit-color without a copy;
the theme switcher lists both. load_user_objects = no in the instance's
system config keeps its menu tree to the system copy (the per-user object
directories are not searched, and a user config cannot turn them back on).
The commands an instance runs get ccfe's own PATH, not the one of the shell
that started it: /usr/bin:/bin:/usr/local/bin:/sbin:/usr/sbin, then the
path of ccfe.conf (default $HOME/bin), then the folder of the menu or
form. The helper programs of an instance must therefore be in one of those
directories, or their directory must be added with path in the instance's
config file.
A menu tree can live in another repository (a deployment repo with an
operator-menu/ directory holding ops.menu, ops.d/*.form and so on) and be
installed on a host as an instance. ccfe-build install-instance does the
whole job in one command:
ccfe-build install-instance ops --from operator-menu # for me: ~/.local/...
sudo ccfe-build install-instance ops --from operator-menu --system --restricted
ops # opens ops.menuIt
- checks every menu and form in the tree with
ccfe -k -Wfirst, and installs nothing if one of them fails, or if the tree holds anything but plain files and directories (a.gitdirectory is left out of the copy); - copies the tree into the instance's object directory,
NAMEunder~/.local/share/ccfe/(--user, the default) or under the system objects directory (--system, which only root may run), replacing an earlier copy by renames; - writes
NAME.confbeside the config (~/.config/ccfe/or the system etc directory), withrestricted = yesfor--restricted; - links
NAMEto the ccfe program, in~/.local/binor/usr/local/bin(--bin DIRchooses another directory).
With --system the installed tree is owned by root with modes 0755 and 0644,
and the result is checked with the rule restricted mode reads by (an object
directory counts only when it, and every directory above it, is owned by root
or by the owner of the installed program and is not writable by group or
others); an install that fails the check is removed again. That is why
--restricted needs --system: restricted mode reads no per-user directory.
ccfe-build check-trusted [-r] PATH... runs the same check on any path.
Running the command again with a changed tree replaces the objects.
ccfe-build uninstall-instance NAME [--system] takes away what the install
made and nothing else: the paths are recorded in NAME.instance next to the
config file, and the command refuses a record that names anything other than
the instance's own paths. An object directory, config file or link that is
already there and was not installed this way is never overwritten.
For constrained deployments (a kiosk, an operator console, a restricted
login), set restricted = yes in the global { } section of the config.
CCFE then disables the F7 shell escape and the runnable-script save, and
refuses the system: / exec: verbs unless the target program is on an
allowlist:
global {
restricted = yes
restricted_allow = top, df, uptime
}
Allowlist entries are bare program names or absolute paths. A bare name
matches only a program found in a system directory of the restricted PATH
(owned by root, not world-writable); an absolute entry matches that exact path.
The typed command's first word is resolved against that PATH only, so
./top, /tmp/x/top or a symlink named top elsewhere do not satisfy an
entry top; a word containing a slash is accepted only when it equals an
absolute allowlist entry. The restricted PATH has no $HOME entry, no .
and no relative entry.
run: items and command: pick-lists follow the same rule under restricted
mode: they are executed as argv from the allowlist, never through a shell, and
a command that uses shell syntax (;, |, &&, redirection, $(...)) or
names a program that is not allowlisted is refused. Write restricted menus
with simple commands, or call a wrapper script that is itself allowlisted.
Outside restricted mode run(argv):PROG ARGS does the same for one item: no
shell, so a field value with ; or $(...) stays one argument.
Do not allowlist a pager or editor (less, more, vi, man, ...) unless
you know it cannot start a shell: such programs have a shell escape (less's
! command). Restricted children get LESSSECURE=1, which turns that escape
off in less, and SHELL, EDITOR, VISUAL and PAGER are pinned to values
that cannot start a shell (/bin/false, /bin/false, /bin/false, cat),
but other programs have escapes of their own.
Saving output. In the output browser, a restricted user types a plain file
name only (no /, not starting with .); the file goes to
$XDG_DATA_HOME/ccfe/saved (default ~/.local/share/ccfe/saved, created mode
0700). "Save as script" is not offered. Everywhere, a save is created with
O_EXCL|O_NOFOLLOW, mode 0600, and never follows a symlink; an existing name is
replaced only after a confirmation, through a temporary file and a rename. An
output filter that names an external program must pass restricted_allow;
the built-in json filter always may.
Field values are always exported to action commands as CCFE_FIELD_<ID>
environment variables (e.g. run:grep -- "$CCFE_FIELD_PATTERN" /var/log/syslog).
%{PATTERN} in an action is also safe: it is replaced by the value as one
quoted shell word (run:grep -- %{PATTERN} /var/log/syslog), so the value cannot
add arguments or commands. %r{PATTERN} is the raw text, for the rare action
that passes shell syntax on purpose, and ccfe -k warns about a placeholder
written inside quotes ('%{PATTERN}'), which would be quoted twice.
Deploying it as a boundary. With restricted = yes, CCFE enforces the
policy so a logged-in user cannot step around it:
system:/exec:are run shell-free (by argv) and never fall back to a shell: a command that cannot be parsed, is empty or cannot be executed is an error (status 127), not a shell command. The allowlist's match therefore holds —system: df; shchaining and%{FIELD}metacharacters cannot reach a shell.run:actions andcommand:pick-lists follow the allowlist too (see above), and so do a form'sinit { }block and a field'sdefault = command:: each is run as a program from the allowlist, and one that needs a shell (;,|,&&,$(...)) is refused.- Once restricted is on, only trusted configuration files and menu/form
object directories are read. A path is trusted when it, and every directory
above it, is owned by root (or by the account that owns the installed
program) and is not writable by group or others. The per-user locations
(
~/.config/ccfe,~/.local/share/ccfe,~/.ccfe) are skipped outright: owners canchmodtheir own files, so making a file read-only does not make it trusted. A system config that setsrestricted = yestherefore cannot be undone from the home directory, and a user cannot add their own menus. For a source install the system config is<prefix>/etc/ccfe.conf; the deb, rpm, apk and OpenWrt packages keep it at/etc/ccfe/ccfe.conf(a real config file that upgrades leave alone) and link it into the program tree. -Rstays on whatever a trusted config file says (restricted = nois overridden), and a configuration reload (Apply configuration now) applies the same rules, so it cannot bring a dropped directory back.- The directory overrides in the environment (
CCFE_PREFIX,CCFE_ETC_DIR,CCFE_BIN_DIR,CCFE_LOG_DIR,CCFE_MSG_DIR,CCFE_OBJ_DIR,CCFE_THEME_DIR,CCFE_KEYMAP_DIR) are ignored under restricted mode and the installed locations are used. If the configuration (not-R) turns restricted mode on while such overrides are set, ccfe restarts itself asccfe -Rwithout them. - Message files are plain data (
ID = "text"), never evaluated as Perl. -d(debug log) is refused unless a trusted config file setspermit_debug = yes, and a refused-dwrites no debug line to the log.
For this to hold, deploy CCFE with root-owned, group/world-unwritable config
and object directories (and parents) and run it as a non-root user (the usual
restricted-login setup). Defence in depth — OS-level confinement (a restricted shell, a
container, seccomp/AppArmor) — is still worthwhile for a true kiosk; the next
two sections say how the login itself is made a boundary. The analysis and
history are in docs/history/M8-AUDIT.md and docs/history/TECH-DEBT.md.
ccfe -R (--restricted) forces restricted mode for a single run regardless of
config — it can only tighten (there is no flag to turn it off) and it also
makes the object search ignore untrusted directories. It's the primitive the
login-shell setup uses.
Configure CCFE → Login / shell setup (the ccfe-login helper) makes CCFE
start automatically on an interactive login or SSH session — for the current
user, or system-wide via /etc/profile.d (which needs root). It hooks the
shell profile and fires on interactive logins only, so scp, sftp,
rsync and ssh host command are unaffected.
Tick restricted kiosk mode and the hook launches ccfe -R and replaces the
login shell with it, so the session is sandboxed and quitting the menu logs out:
ccfe-login install-user -r # kiosk menu for me on next login
ccfe-login install-system -r # kiosk for every user (needs root)
ccfe-login status # what is enabled
ccfe-login uninstall-user # undo
ccfe-login --help # usageThe block it writes is marker-delimited and idempotent, so it is safe to re-apply or remove. A restricted CCFE session refuses these reconfiguration commands.
Bash reads only the first of ~/.bash_profile, ~/.bash_login and ~/.profile
that exists. ccfe-login status and install-user warn when a hooked file
sits behind one of those that has no hook, since a bash login never reaches it.
This is not a security boundary on its own. The hook lives in a file the
user owns and runs only when a login shell reads it. A user can bypass it with
ssh -t host bash (an interactive shell that is not a login shell reads no
profile), by editing ~/.profile over sftp, by creating ~/.bash_profile
(bash reads it before ~/.profile), or with commands in ~/.ssh/rc, which
runs before the shell. Use ccfe-login for a convenience menu. For a kiosk,
make the login itself run ccfe:
- In
sshd_config, give the kiosk account (or group) aMatchblock withForceCommand /usr/bin/ccfe -R(or/usr/bin/NAME -Rfor an instance),AllowTcpForwarding no,X11Forwarding noandPermitTTY yes.ForceCommandapplies to every shell, command and subsystem request and ignores~/.ssh/rc; because it covers subsystems,sftpstarts ccfe, not a file server, so do not enableinternal-sftpfor that account. - The command is run through the account's login shell with
-c. Give the account a login shell that is not an interactive shell: a root-owned wrapper, listed in/etc/shells, that doesexec /usr/bin/ccfe -Rand ignores its arguments. - Install the config and menus root-owned and not writable by group or
others (
ccfe-build install-instance NAME --from DIR --system --restricteddoes that); restricted mode reads nothing from the account's home. Keep the home directory itself root-owned, or at least without writable startup files (~/.profile,~/.bashrc), so the shell that runs the command finds none of the user's own to read. - Allowlist the programs the menus run in
restricted_allow, and give the account nosudorights beyond what those menus need.
CCFE is monochrome by default. When the terminal supports colour (and
NO_COLOR is unset and the layout is not Simple), it pre-creates the
standard foreground colour pairs, so any *_attr setting in the config may
reference COLOR_PAIR(n) — 1=red, 2=green, 3=yellow, 4=blue,
5=magenta, 6=cyan, 7=white — over the terminal's own background,
combinable with A_BOLD etc.
This themes the run-output (browser_global), the form fields (field_attr
/ active_field_attr), and the menus themselves (menu_global):
menu_global {
screen_attr = COLOR_PAIR(6) # menu text
selected_attr = COLOR_PAIR(3) | A_REVERSE | A_BOLD # highlight bar
title_attr = COLOR_PAIR(3) | A_BOLD # header
key_attr = COLOR_PAIR(2) | A_REVERSE | A_BOLD # control keys
}
browser_global {
stderr_attr = COLOR_PAIR(1) | A_BOLD # errors in red
}
title_attr (the header) and key_attr (the function-key bar) apply to every
screen — menus, forms and the output browser.
CCFE was modelled on AIX's SMIT, and ready-made themes ship under
<prefix>/share/ccfe/themes/: ccfe.conf.smit (the classic monochrome
original), ccfe.conf.smit-color (the colour version, including the header
and control keys), ccfe.conf.smit-panel (a panelled white-on-blue look)
and ccfe.conf.console (a Linux-console scheme). Drop one in as your
ccfe.conf, or use it as an instance config: symlink the binary to smit and
save it as smit.conf, so smit gets its own scheme and menu tree (see One
binary, many menu trees above).
You don't have to copy a theme by hand, though — Configure CCFE → Select colour theme lists the installed themes and applies the chosen one at runtime (next section).
Run ccfe config (or pick Configure CCFE from the default menu) for a
guided wizard that writes your personal ~/.config/ccfe/ccfe.conf — screen
layout, behaviour toggles, shell/restricted mode, config variables — without an
editor. Several settings can then be changed while CCFE is running and take
effect immediately, no restart:
-
Reload — Apply configuration now re-reads the config and re-applies colours, key bindings, behaviour toggles and variables. (Switching the mouse or the screen layout, and restricted mode, still need a restart; the confirmation says so.) Bind it to a key with
keymap { reload = ... }for instant feedback while you tune a config. -
Colour themes — Select colour theme lists the themes installed under
share/ccfe/themes/, saves your choice and applies it at once — the same as atheme = NAMEline in the config. -
Key maps — Select key map switches between shipped presets: classic (the F1–F9 SMIT layout), safe (every function also on an Alt-letter, for terminals that grab the function keys), nano (Ctrl chords) and mc. Or spell your own out with a
keymap { }section — each function can take an F-key, a Meta chord (M-l), a Ctrl chord (^G) or a plain key, primary plus alternates:keymap { list = F2, M-l back = F4, M-b, ^B exit = F9, M-q }(A lone
Escis still "cancel"; Meta chords are disambiguated by a short timeout — theESCDELAYenvironment variable, default 25 ms.) -
Search — Search menus and forms (also on the main menu, or bind the
searchfunction to a key) scans every menu and form on the path — titles, top/bottom text, item descriptions and form field labels — and jumps to the match you pick.
Notifications. Point notify_file at a file and CCFE shows its contents as
a banner whenever a menu screen is entered (each distinct write once) — a simple
way for a producer to reach a logged-in operator.
A menu does not have to be written by hand when a program can describe what it
offers. ccfe-gen turns a machine-readable description into menus and
forms, so that a new item appears in the menu with no file to edit. The first
generator is ctrl-exec, which builds an operator menu for the
ctrl-exec command dispatcher from its
discovery output (the example here; CCFE does not depend on it):
ctrl-exec-cli discovery --json > discovery.json
ccfe-gen ctrl-exec --from discovery.json --out ~/.local/share/ccfe/ccfe
ccfe ctrl-execWithout --from the tool runs ctrl-exec-cli discovery --json itself; --cli
names the program.
What it writes into the objects directory given with --out:
ctrl-exec.menu– Fleet status, Run status (asks for a request id) and one item per script name, the union over every host that reports it.ctrl-exec.d/NAME.form– one form per script. The first field, Hosts, is a multi-value pick-list of the hosts that report the script. The other fields come from the script's schema: a boolean gives its flag, a string or integer is a text field, anenumis a pick-list, an array is a field where each blank-separated word is one argument;default,requiredand the first sentence of thedescriptionare used too. A script with no schema gets one free Arguments field.
Each action is run(argv):ctrl-exec-cli run --json %{HOSTS} --script NAME -- ...
with the built-in json output filter. It runs without a shell and every field
value is one quoted word, so a value cannot add an argument or a command.
confirm is added unless the schema says the script is read-only. A script that
needs confidential input gets no form.
The tree is built beside the old one, every file is checked with ccfe -k and
only then renamed into place; if anything fails nothing changes. Only
ctrl-exec.menu and ctrl-exec.d in the objects directory are replaced, so
menus written by hand next to them are never touched. Run the command again when
the scripts of the fleet change. ccfe runs ctrl-exec-cli by name from its own
path plus the path of ccfe.conf; add the program's directory there (or
give --cli an absolute path), and under -R list it in restricted_allow.
See ccfe-gen(1).
A form that fronts a command-line program with hosts and arguments (the worked
example is ctrl-exec-cli; ccfe does not depend on it) needs these rules. The
full text is the CALLING AN EXTERNAL CLI section of man ccfe_form.
%{ID}is the field value as one quoted shell word;%r{ID}is raw text;$CCFE_FIELD_<ID>holds the value as typed. Never put%{ID}inside quotes.run(argv):PROG ARGSruns without a shell, so a value cannot add an argument or a command.run:uses the shell;system:hands the terminal to the command.- The command of a
run:action has no terminal: its standard input is empty (end of file at once), so a prompting program sees no answer. Pass a non-interactive flag (--yesor similar) as an argument, or usesystem:. Pick-list, default andinitcommands also read an empty standard input. - Commands get ccfe's own
PATH(/usr/bin:/bin:/usr/local/bin:/sbin:/usr/sbin, thepathofccfe.conf, the form's folder): add the program's directory topath, or write its absolute name. - The browser shows
Ok,FAILED [ES=n]orSIGNAL N(status 128+N).filter = json(menu item) orfilter { json }(form) shows JSON output as a table;json(value=KEY,descr=KEY,path=a.b):COMMANDfills a pick-list from JSON, and the command can use other fields, so a list can depend on one above it. An empty or failing list leaves the field's value unchanged. A list command that exits non-zero is an error (shown with its exit status, its output discarded): writegrep ... || truewhere an empty result is normal. run(confirm):asks Yes/No (default No).logwrites a form action's command to the log, with hidden values as****.- Under
-Rthe program must be inrestricted_allowand the action must be a plainrun(argv)command; the files must sit in a root-owned directory. ccfe-genwrites such forms from the CLI's own description, andccfe -k -Wchecks them;ccfe-build install-instanceinstalls the tree as an instance (see above). Persistent field values (persist = YES) are saved under$XDG_DATA_HOME/ccfe/persistent, mode 0600, never through a symlink.
A minimal image (stock Debian + libcurses-perl) is provided:
docker build -t ccfe .
docker run -it ccfe # opens the demo menu
docker run -it ccfe sysmon # jump straight to a menu/formdocker-compose.yml turns CCFE into a throwaway shell of the admin/debug
commands you always forget — disk/memory/CPU, processes, networking, plus
parameterised find and tail forms and a Docker submenu:
docker compose run --rm ccfe # open the remembered-commands menu
docker compose run --rm ccfe demo # or jump to the bundled demo / sysmonThe curated objects live in docker/objects/ and are bind-mounted read-only, so
you can edit the menu on the host without rebuilding (ccfe -l points at them).
The Docker items (docker ps / images / logs) need the host daemon — un-comment
the docker.sock mount in the compose file to enable them.
CCFE is extended purely by dropping declarative files onto its search
path, which for a binary invoked as ccfe is, in order:
<prefix>/share/ccfe/objects/ccfe/— system-wide~/.local/share/ccfe/ccfe/— your XDG data dir~/.ccfe/ccfe/— legacy fallback
The building blocks:
| File | Purpose |
|---|---|
<name>.menu |
a static menu — a list of items, each with an action |
<name>.menu/ directory |
a dynamic menu — a definition file plus any number of *.item files merged into it |
<name>.form |
a form — labelled fields the user fills in |
<name>.d/ directory |
a collection of related .form files |
*.item |
a single menu item a plugin drops into another menu's .menu/ directory to graft itself on |
Items dispatch through action verbs — menu: (open another menu), form:
(open a form), run: (run a command in a pager-like view), system: (hand
the terminal to a full-screen command), and exec: (replace CCFE with the
command) — with modifiers like (confirm,log,wait_key). Form fields can be
populated from a list_cmd (command: runs a program, const: is a fixed
list, json(value=KEY,descr=KEY,path=a.b): runs a program that prints JSON)
so dropdowns can be driven by live data.
src/ccfe-plugin-sysmon/ is a complete, installable example. It ships:
sysmon.menu— its top-level menu (ps,top, asarform, …),sysmon.d/*.form— the forms those items open,sysmon.item— the one item that graftssysmononto thedemomenu,- its own
install.sh.
A tree of your own that should not graft onto another menu but run as a front-end of its own is installed as an instance instead (see Running a menu tree as its own instance above).
Install it and run it:
cd src/ccfe-plugin-sysmon
sh install.sh
ccfe sysmon # or pick "System resources..." from `ccfe demo`The quickest start is a per-user menu. Create
~/.local/share/ccfe/ccfe/mytools.menu:
title {
My tools
}
item {
id = DISK
descr = Disk usage
action = run:df -h
}
item {
id = LOGS
descr = Tail the syslog
action = run:tail -n 200 /var/log/syslog
}
item {
id = NET
descr = Network connections
action = run:ss -tunap
}
Each attribute goes on its own line: a one-line item { id=DISK descr=... }
reads the whole line as the id.
Then run ccfe mytools. See the manual pages under src/man/ (or
man ccfe, man ccfe_menu, man ccfe_form after install) for the full
file-format reference, and ccfe-plugin-sysmon for forms and list_cmd
dropdowns.
CCFE can build CCFE. Run ccfe builder for a guided menu that creates and
extends menus, forms and config — writing them to your XDG directory and
validating each with ccfe -k. It is plain CCFE content driving the
ccfe-build helper (the same helper is usable directly:
ccfe-build new-menu mytools "My tools", ccfe-build add-item mytools DISK "Disk usage" "run:df -h"). Field values reach the helper through
$CCFE_FIELD_*, so nothing you type is shell-interpolated.
The builder also manages ownership of your objects — Change object
ownership (ccfe-build chown <name> <user>[:<group>]) lists the local users
and groups to pick from and changes the owner of one of your own menus/forms.
Changing to another user usually needs root; if you lack the privilege the
error is shown plainly.
Validate a menu or form without opening the UI (useful for authors and CI):
ccfe -k mytools # OK: menu "mytools" -- title "My tools", 3 item(s)
ccfe -k sysmon.d/sar # checks a form; exits non-zero on a mistake
ccfe -k -W mytools # -W (--strict): a warning fails the check too-k exits 0 when the object is sound, 1 when it is not and 2 when the name is
not on the search path. It reports each mistake as an ERROR line, with the
file and line where it is known, and exits 1 on any of: a parse error; a menu
or form with an empty title; an item with no description; an item or form with
no action; an unknown verb or an option the verb does not take (rnu:,
run(cnofirm):; argv is for run, wait_key for system); an action
verb that needs an argument and has none; a list field whose list_cmd source
is none of command:, const: or json(...):. The checks for a placeholder
written inside quotes ('%{ID}') and for a field option ending in a quote are
WARNING lines: they exit 0, and exit 1 under -W. Use -W in CI and when
installing a menu tree for others.
Your CCFE setup is just text files: the config (ccfe.conf, plus any instance
<name>.conf) and your menus/forms. The system copies sit under your install
prefix; your personal additions follow the XDG base directories:
<prefix>/etc/ccfe.conf system config
<prefix>/share/ccfe/objects/<name>/ system menus & forms
~/.config/ccfe/<name>.conf your per-user config
~/.local/share/ccfe/<name>/ your per-user menus & forms
Because they're plain text, put them under version control — keep a small git repo of just your config and menus so changes are reviewable and reversible:
mkdir -p ~/ccfe-config && cd ~/ccfe-config
git init
cp -r ~/.config/ccfe ./config # your per-user config, if any
cp -r ~/.local/share/ccfe ./menus # your per-user menus, if any
git add . && git commit -m "My CCFE config and menus"To restore on a new machine, install CCFE (or the package), then copy these
files back into place. ccfe -k <name> parse-checks a menu/form after you
restore or edit it.
make check # the gate: tests + perlcritic + perltidy + shellcheck
make test # the tests onlymake check is what CI runs. It sets CCFE_REQUIRE_PTY=1, so a test that
would skip fails instead: no pseudo-terminal, no Curses, no installer, a
failed install, a missing tool or running as root all count as failures, and
a green gate never means "skipped quietly". The one skip that stays a skip is
a run-time optional module (Text::CharWidth), whose TAP line names it.
Without that variable (plain make test, or prove -lr t/ from src/) such
tests skip, which suits a build sandbox with no /dev/pts. The packages the
whole gate needs are listed in docker/ci/packages.txt.
A perlcritic, PPI or perltidy of another version than your own can disagree with your local gate. CI runs the gate in a container image, and you can run the same image locally, without pushing:
docker build -t ccfe-ci docker/ci # once, and to pick up a newer Ubuntu
./scripts/ci-local # make check in that image, no networkci-local checks the working tree as it is (committed or not) in a
temporary copy, as an unprivileged user with your uid. To compare another
toolchain, build the image from another base and point ci-local at it:
docker build --build-arg BASE=ubuntu:24.04 -t ccfe-ci:24.04 docker/ci
CCFE_CI_IMAGE=ccfe-ci:24.04 ./scripts/ci-localThe suite (400+ tests) covers: that the program compiles; that the .menu /
.form / .item plugin file formats still parse against the demo and sysmon
fixtures; a source-level regression guard for the historical form crash; the
pure CCFE::* parser/layout/theme modules; and end-to-end terminal tests that
batch-install CCFE into a temp prefix and drive it on a real pseudo-terminal —
including the runtime reload, theme/key-map switching (and Meta-key bindings),
menu search, notifications, ownership and login-shell setup. The parser tests
load the program headlessly (via the CCFE_TESTING guard) so no terminal is
required; the tty tests bring their own pty (pure core Perl, see
t/lib/CCFE/Test/Pty.pm) and skip themselves where no Linux pty / Curses is
available (unless CCFE_REQUIRE_PTY=1, above). Every test that runs ccfe
loads CCFE::Test::Env (the pty helper does it for the pty tests), which gives
it a private HOME and XDG tree, so the developer's own configuration is never
read. The pty helper always gives the program a fixed TERM=xterm, whatever
the developer's terminal is. A pty test ends by checking the program's log
(CCFE::Test::LogGate): any Perl warning that ccfe wrote there while curses
owned the screen fails the test; the list of excused warnings is empty.