Skip to main content
Version: 4.1

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 --json if 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 alice need 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 way enterprise <module> license <key> now prints "Licence key saved for <module>.").

Failure​

  • Exit codes: 0 success, non-zero failure. Most commands use 1 for "the requested thing could not be done" and some (enterprise, module) use 2 for "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 on 2 meaning 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 with Error: ... in it, or JSON that will not parse. New commands must use eprintln!, never println!, for anything prefixed Error:.

    .github/scripts/cli_conventions.sh in 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-quotas and email-sendlimit answer Error: HiTechCloud Community edition does not support ... on stdout, because hitechcloudpanel/internal/modules/emails/webmail.go matches 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) got License: MIT and 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, or help) lists every command with a one-line description and a usage string, sourced from each .sh script's # Description:/# Usage: header comment, or - for a command that has moved to Rust and has no script any more - the COMMANDS table in cli/src/main.rs (name|description|usage, read by hitechcloudcli commands). A new Rust command must add its row to that table, or hitechcloudcli --help silently stops listing it, which is worse than a bad description.

  • hitechcloudcli <command> --help is answered centrally, for all 176 commands, from the same COMMANDS table 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 as hitechcloudcli, and main() before clap when it is invoked under any other name, such as the hitechcloudcli symlink every hardcoded caller uses.

    That second half was missing, and it mattered: a command that does not parse --help takes it for a positional value and runs. hitechcloudcli domains-whoowns --help looked up a domain called --help; files-fix_permissions --help read 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 --help now 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 (see user/suspend.rs, ftp/add.rs). A command whose verbs are worth listing should print them from its own usage() when given a verb it does not know; adding one is always safe, since the central --help is 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 inconsistencyWhat fixing it would cost
domain vs domains-*Unrelated features, same stem, first-tab collisionA 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 --helpBare invocation sometimes prints usage, sometimes nothing, sometimes an unrelated error from missing required argsAuditing 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 --jsonPanels that later want to parse one of them must add it then, one at a timeNot 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 parsersAn operator must remember which style a given command takesUnifying 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).
Was this helpful?