CLI conventions
hitechcloudcli has grown to roughly 170 commands across several eras -
shell scripts from 2023, a wave of Go-backed helpers, and an ongoing port to
Rust (hitechcloudcli/cli, shipped as hitechcloudcli). Nothing
about that growth was disorderly by accident: each era solved its own
problem and moved on. But an operator typing commands, and a panel parsing
their output, both need one answer to "what does a command look like", not
three. This page is that answer for anything new, and a map of where the
existing ~170 commands still disagree with it.
Both panels (hitechcloudpanel, hitechcloudadmin) shell out to these
commands and parse their stdout/exit code. That makes an incompatible
change to an existing command a broken panel. The rule below is therefore
almost always "add a missing convention", never "rename to fit one" - see
What we did not rename.
How a command is named
The dispatcher (hitechcloudcli, the top-level bash script) derives a
command's name from where its script lives, not from a naming committee:
- A script directly in
hitechcloudcli/(e.g.config.sh,domain.sh,waf.sh) keeps its bare name:config,domain,waf. - A script in a subdirectory takes
<directory>-<file>:ftp/list.sh→ftp-list,native/wireguard.sh→native-wireguard,user/suspend.sh→user-suspend.
So native-*, user-*, email-*, ftp-*, domains-*, plan-*,
toolkit-*, websites-* are not competing conventions - they are the same
one rule, applied to whichever directory a command's owner put it in. That
is principled, and new commands should keep using it: a command about
websites/domains goes under domains/, not at the top level, unless it is
the kind of server-wide setting (like domain, the server's own hostname)
that genuinely has no per-account meaning.
The one collision worth knowing about: domain (singular, root-level)
sets the server's own hostname; domains-add, domains-delete,
domains-dns, ... (plural, under domains/) manage customer websites. They
are unrelated features that happen to share a stem. This is not a bug - git mv-ing either one breaks every caller - but it is a genuine "looks
inconsistent" trap for an operator typing hitechcloudcli domain<TAB>. New
commands must not add a second one of these: check hitechcloudcli --help
for your stem before choosing a name, and if a near-miss already exists,
say so in the command's own --help text (a two-line "not what you're
looking for? see domains-*" costs nothing and saves a support ticket).
Argument shape
Every command is a clap subcommand at the top level
(hitechcloudcli/cli/src/main.rs), but the large majority - 182 of the
current ~198 command implementations - declare their subcommand as:
#[command(name = "ftp-list", disable_help_flag = true)]
FtpList {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
clap only routes the command name; the command's own run(&args) parses
its Vec<String> by hand, and disable_help_flag hands --help/-h
handling to that same code. This is deliberate, not an oversight: it is how
a Rust twin keeps a shell script's exact argument grammar (positional order,
a bare flag, an --opt value pair) byte-for-byte, which is what "the panel
must not notice this became a binary" requires.
A little under a dozen newer commands (wireguard peers --user, ipmanager list, packages show|has|enable|disable, rdap-scan --lines/--top, conf)
instead give clap a real typed shape:
Wireguard { #[command(subcommand)] cmd: WgCmd },
...
enum WgCmd { Peers { #[arg(long)] user: Option<String> } }
For a genuinely new command with no shell precedent to match, prefer the
typed shape. It gives you --help and error messages for free, and it is
what stops the next inconsistency below from happening again. A command that
is a Rust port of an existing script's argument grammar should keep the
grammar it is replacing (trailing_var_arg) rather than "fixing" it into a
typed shape the script never had - that fix is a breaking change to every
caller of the script.
What already varies inside the hand-rolled parsers, because nothing
forced consistency: some flags are --key value (two tokens: if x == "--bg" { background = true }), some are --key=value (one token, via
split_once('=')), and a few commands mix both for different flags in the
same invocation. New flags on an existing hand-parsed command should match
that command's own existing style, not introduce the other one next to it.
Output
--json: supported by about 32 of ~198 commands today (the ones a panel or another script actually needs to parse). A command a panel calls MUST support--jsonif it does not already print JSON unconditionally. A command that is purely interactive (an operator reads it, nothing parses it) does not need one, but adding it is cheap and future-proofs the command for the next panel page that wants it.- A plain table/text is the default for everything else - keep it readable; do not invent a third format (CSV, key=value dumps) unless the command already had one before it was ported.
- Silence on success is a valid answer for a command that changed one
thing an operator already named (
user-suspend aliceneed not repeat "alice suspended" back) but a command should say what changed when the before/after state was ambiguous (an add/assign/allocate-shaped command should confirm what it did and to what, the wayenterprise <module> license <key>now prints "Licence key saved for<module>.").
Failure
-
Exit codes:
0success, non-zero failure. Most commands use1for "the requested thing could not be done" and some (enterprise,module) use2for "the arguments themselves don't parse" - that split is worth keeping for new commands (a script deciding whether to retry can tell "bad input, don't retry" from "transient failure, maybe retry" without scraping the message), but it is not consistently applied today and a caller must not rely on2meaning that everywhere yet. -
stderr for errors: every command sends its error text to stderr via
eprintln!, with one documented exception below. This was measured rather than estimated when it was fixed: 75 messages in 26 files were being printed to stdout, not the "at least a dozen" an earlier reading of this page guessed at, and 70 of them moved. stdout is for the thing that succeeded, and the panels read it at 193 call sites through.Output(), so an error there arrives where data was expected - a list withError: ...in it, or JSON that will not parse. New commands must useeprintln!, neverprintln!, for anything prefixedError:..github/scripts/cli_conventions.shin the hitechcloudcli repository fails a build that adds one back.The exception, and why it is not a bug:
api,email-setup,email-webmail,email-quotasandemail-sendlimitanswerError: HiTechCloud Community edition does not support ...on stdout, becausehitechcloudpanel/internal/modules/emails/webmail.gomatches that text to decide where the webmail link points:if strings.Contains(output, "HiTechCloud Community edition") {return "http://localhost", nil}That is the command's answer, not a failure - and moving it to stderr breaks webmail on every Community server, silently. Each of those five lines carries a comment saying so. It is also the reason the move was not a sweep: every message was matched against both panels' sources first, in every window of three or more words, and four of the hits turned out to be the panels' own UI strings rather than a parser of ours.
-
Say what to do next. The dispatcher's own header (
hitechcloudcli,execute_command()) carries the scar tissue for this: an unknown command used to fall through to the usage screen, the command history, and the about screen, so a caller reading "the last line of the output" (the admin's backup page did exactly that) gotLicense: MITand reported "Backup failed: License: MIT" to an administrator. The fix that is now the house rule:The error is the answer. The suggestion goes with it, on stderr, where an error belongs.
A ported command whose binary is missing says so and how to fix it (
"'$command' runs in hitechcloudcli, which is not installed... Install it with: hitechcloudcli update"), not "command not found". New commands should hold themselves to the same bar: an error message names what went wrong AND what the operator can do about it, on stderr, and nothing else goes to stderr that isn't part of that answer.
Help
-
hitechcloudcli --help(or-h, orhelp) lists every command with a one-line description and a usage string, sourced from each.shscript's# Description:/# Usage:header comment, or - for a command that has moved to Rust and has no script any more - theCOMMANDStable incli/src/main.rs(name|description|usage, read byhitechcloudcli commands). A new Rust command must add its row to that table, orhitechcloudcli --helpsilently stops listing it, which is worse than a bad description. -
hitechcloudcli <command> --helpis answered centrally, for all 176 commands, from the sameCOMMANDStable the full listing is built from: the command's name, description and usage. Both of the binary's personalities do it - the dispatcher when it is invoked ashitechcloudcli, andmain()beforeclapwhen it is invoked under any other name, such as thehitechcloudclisymlink every hardcoded caller uses.That second half was missing, and it mattered: a command that does not parse
--helptakes it for a positional value and runs.hitechcloudcli domains-whoowns --helplooked up a domain called--help;files-fix_permissions --helpread it as the account whose files to chown. Nothing existed under those names, so nothing was damaged, but the command ran.every_command_has_help_text_to_print(rustcli's own tests) asserts that all 176 rows have a description and a usage string that names the command, since that table is what--helpnow prints. -
Per-command help of a command's own - a
usage()covering its verbs and flags - exists in about 70 implementations. The rest rely on the central entry above plus the generic "wrong number of arguments" branch (seeuser/suspend.rs,ftp/add.rs). A command whose verbs are worth listing should print them from its ownusage()when given a verb it does not know; adding one is always safe, since the central--helpis what an operator reaches first. -
Whatever text a command prints for help must match what the command actually does now, not what its script did in 2023. A stale help string that still names a flag the Rust port dropped, or omits one the port added, is worse than no help text, because it fails silently.
What we did not rename
Every naming/shape/output oddity catalogued above has a fix that is a
rename or a reshape - and every one of those is a breaking change for
hitechcloudadmin and hitechcloudpanel, which parse these commands'
stdout by exact shape (see internal/handlers/*.go's many exec.Command ("hitechcloudcli", ...) calls) and, for the shell scripts still standing,
by shell callers elsewhere in this codebase. None of those are touched here.
What was fixed instead is additive only: a --help/usage story that did not
exist, an exit code, a --json a panel needed. The commands below are the
ones with a real gap that is genuinely a rename or an argument-shape change
- listed so the cost is visible, not so it gets done casually:
| Command(s) | The inconsistency | What fixing it would cost |
|---|---|---|
domain vs domains-* | Unrelated features, same stem, first-tab collision | A rename breaks the hostname-setting workflow embedded in install, UPDATE.sh, and any operator's muscle memory or script; the honest fix is the --help cross-reference already added, not a rename. |
The ~130 commands with no dedicated --help | Bare invocation sometimes prints usage, sometimes nothing, sometimes an unrelated error from missing required args | Auditing and adding real --help handling to 130 files individually is the actual amount of work "the CLI is inconsistent" implies - large, mechanical, and safe to do incrementally (module by module, verified per module), but not in one PR. |
The ~166 commands without --json | Panels that later want to parse one of them must add it then, one at a time | Not a cost to defer - --json should be added when a caller needs it, not speculatively for commands nothing parses yet. |
Mixed --key value / --key=value within hand-rolled parsers | An operator must remember which style a given command takes | Unifying every hand-rolled parser onto one flag style is, in effect, rewriting ~180 files' argument parsing - the single highest-risk, lowest-value item on this list, since every caller (panel, script, muscle memory) that already knows a command's current flag style would need to be re-verified. Left alone; new flags on an existing command match that command's own style (stated above). |