Skip to content

CLI UX Guidelines (tomix)

Condensed from Command Line Interface Guidelines (CC BY-SA 4.0), adapted to this repo. Where a rule is already enforced by shared infrastructure, use that infrastructure — do not re-derive it in a command. See the wiring map at the bottom for where each concern lives.

Philosophy

  • Human-first by default; machine-readable on request. A TTY means a human is reading.
  • Simple parts that compose: output of tx should be usable as input to other tools.
  • Be consistent with existing conventions (ours: the filesystem metaphor — ls, get, rm, mv, find) so users can guess commands they have never seen.
  • A CLI is a conversation: confirm state changes, suggest the next command, make failure recoverable. Say just enough — silence looks broken, noise hides signal.

The basics (non-negotiable)

  • Parse with System.CommandLine only; never hand-parse argv.
  • Exit 0 on success, non-zero on failure. Use the documented exit codes via CommandOutput; never invent ad-hoc codes in a command.
  • Data goes to stdout. Messages — banners, progress, warnings, errors, hints — go to stderr (ErrorOutput for diagnostics, StdErr.Console() / StdErr.MarkupLine for everything else). A user piping tx ls or tx validate to a file must get only the result. Rule of thumb: if removing the line loses data, it is stdout; if it only explains or suggests, it is stderr.

Help

  • -h / --help and tx help <command> show full help; running a parent command bare (tx, tx bpa) shows its help and exits 0. Never make bare invocation an error.
  • Lead with examples. Every command's help gets an Examples: block showing the 2–3 most common invocations; complex syntax (e.g. ls path filters) is taught by example, not by grammar.
  • Order help by frequency of use: most common commands and flags first. Root help lists commands before global options; a command's page lists its own options and names the globals on one line instead of repeating them.
  • Group subcommands into sections in root help (Discover / Modify / Connect / Validate / Manage). A flat list of 30+ commands is unusable.
  • Group a long option list with HelpGroups tags (Save options:, Rule options:, ...). The mutation lifecycle flags are tagged by LifecycleOptions; compatibility forms go in Compatibility options:, last.
  • Help wraps to the terminal width (capped at 100) with hanging indents, and is not wrapped when redirected. Every page must fit in 80 columns (HelpLayoutTests).
  • Command descriptions are one line of at most 60 characters with no trailing period and no flags; detail goes in SpectreHelpAction.CommandNotes. Option descriptions: one sentence has no trailing period, several sentences each end with one, and defaults are written (default: x).
  • Every option that takes a value names it in HelpPlaceholders (--save-to <path>, not --save-to <save-to>).
  • On typo or invalid subcommand, print one TOMIX_UNKNOWN_COMMAND diagnostic with the closest match ("Did you mean ls?") on stderr — never the help page.
  • Format help with the Styling role palette (see docs/cli-color-strategy.md); link to web docs at the end.

Output

  • Human-readable output is paramount; check whether stdout/stderr is a TTY and degrade gracefully when it is not.
  • Machine output where it doesn't hurt humans: --output-format json|csv per command through JsonOutput/CsvOutput; --paths-only-style flags where a plain one-record-per-line form aids piping (clig's --plain).
  • On success, print something brief — silence reads as a hang — but err toward less. Support --quiet (alias -q) to suppress non-essential output.
  • If you change state, say exactly what changed and what the new state is (model object counts, target workspace, file written). Make current state easy to inspect (session, stage, doctor are our git status equivalents).
  • Suggest the next command after workflow steps (Slate Guidance), and give every empty result a message plus a hint — never print nothing.
  • Crossing the program boundary (network calls, writing files not named by the user) should be visible: name the server/file on stderr as it happens. A command that opens a model resolved implicitly from the active connection prints one muted stderr line (Connected to: …) naming it; explicit model/--server/--recent targets stay silent, and --quiet/JSON/CSV suppress the line entirely.
  • Color with intention only, via Styling helpers — one warm accent per line, semantic roles only. Never hard-code ANSI or markup in commands.
  • Disable color when: stdout/stderr is not a TTY (check each stream separately), NO_COLOR is set and non-empty, TERM=dumb, --no-color/config says so. [tomix: NO_COLOR (non-empty) and config are handled in Program.cs; Spectre detects TERM=dumb and redirected output natively.]
  • No spinners or animations when not a TTY (CI logs fill with frames otherwise).
  • Don't print internals only the authors understand; debug detail belongs behind --verbose, not in default stderr.

Errors

  • Catch errors and rewrite them for humans. Every error answers: what happened, why, how to fix it. Never let a raw stack trace or XMLA/HTTP exception reach the user by default.
  • Put the most important word first; users read the first line and the last line.
  • Multiple errors: group and summarize; don't interleave with normal output.
  • Unexpected errors: print a short message, write detail to a debug log or show it under --verbose, and tell the user where to file an issue.
  • Render through ErrorOutput so --error-format json keeps working.

Arguments and flags

  • Prefer flags to positional arguments; flags self-document at the call site.
  • Every flag has a full-length name; one-letter aliases only for the most-used flags. Use standard names where they exist: -q/--quiet, -f/--force, -o/--output, --json, --no-color, --no-input, --version, --dry-run.
  • Two positional arguments meaning different things is a smell; more than two is a bug. (Multiple args of the same kind — file lists — are fine.)
  • Defaults should be correct for the majority; flags adjust, never enable basic usability.
  • Support - for stdin/stdout where a file path is accepted.
  • Never accept secrets via flags (they leak into shell history and ps). Take them from files, prompts, or the credential store — and not from env vars either.
  • Validate input early and report all problems with the offending value echoed back.

Interactivity

  • Prompt only when stdin is a TTY. When it is not, never block on a prompt — fail fast with the flag that would have answered it ("pass --yes to confirm").
  • Never require interactivity; every prompt has a flag equivalent. Honor --no-input to forbid all prompting. [tomix: gap — add to GlobalOptions.]
  • Confirm before anything destructive or remote-mutating (rm, replace, deploy, stage commit/discard, mv --save/--revert, bpa run --fix --allow-delete/--revert, and the partition-risky refresh variants): mild = y/n, severe = type the object/workspace name, catastrophic = require an explicit flag.
  • Mask password input; let the user escape (Ctrl-C must always work in prompts).

Subcommands

  • Be consistent across subcommands: same flag names for the same concepts (--model, --server, --output-format are recursive globals — keep it that way).
  • Keep one shape (noun verb like bpa rules list); no ambiguous near-duplicate names; no catch-all subcommand that guesses intent; no arbitrary prefix abbreviations (tx dep must not silently mean deploy).

Robustness and responsiveness

  • Responsive beats fast: print something within ~100 ms; show a Spectre Status/ progress for anything over ~1 s (connects, formatter API calls, deploys) — TTY only.
  • Network operations time out and fail with a actionable message; partial work is recoverable (idempotent retries; staged changes survive a crash).
  • Validate user input; expect misuse; make operations crash-only where possible (no corrupt state if killed mid-run).
  • On Ctrl-C, exit as soon as possible; print a note before any slow cleanup, and let a second Ctrl-C skip it.

Future-proofing (output is a contract)

  • JSON field names, exit codes, flag names, and subcommand names are public API. Keep changes additive; deprecate with a warning period before removing or renaming; changing human-oriented text is fine.
  • Don't repurpose a flag to mean something different — add a new one.

The two JSON shapes

The streams answer different questions, so they have different shapes. Don't merge them.

stdout — the command envelope. --output-format json wraps every command's payload:

{ "data": <the command's own shape>, "diagnostics": [] }

diagnostics is always present and currently always empty — no handler emits a non-fatal diagnostic yet, and a command with no data writes nothing to stdout. It is part of the contract so that a command which succeeds with something to say has somewhere to say it without a breaking change. Rendered by CommandEnvelope<T>; scripts read .data.

stderr — the error object. A single failure, shaped for the one thing a caller wants to branch on (see error-codes.md):

{ "error": "...", "code": "TOMIX_...", "severity": "Error", "hint": "..." }

Emitted under --error-format json, and implied by --output-format json so a JSON caller never has to parse text off stderr. GlobalOptions.ErrorFormatValue resolves that rule for every command. No CommandOutput.Render overload lets a command skip the decision: each either takes the ParseResult and derives the format, or requires it as a non-optional argument — enforced by ErrorOutputContractTests.

Outside the envelope, because they are not command results:

Output Shape Pinned by
--output-format csv tabular rows CommandEnvelopeContractTests
get --output-format tmdl\|bim\|tmsl a model fragment a consumer feeds back to a serializer CommandEnvelopeContractTests
deploy --xmla - in text mode the raw TMSL script, for the engine CommandEnvelopeContractTests
query --output-file <path> a data file for jq/pandas — written to the file, never stdout QueryOutputFileContractTests

deploy --xmla --output-format json is not an exception: JSON mode is a command result like any other, enveloped as usual. Only the text-mode stdout is raw, and only for --xmla - — with --xmla <file> the script goes to the file and text mode prints a human summary instead.

diagnostics appears at two levels, and they are different things. The envelope's .diagnostics holds CLI diagnostics (code/severity/message/hint) and is empty today. A command's own payload may also have a field called diagnostics — bpa run's .data.diagnostics is its non-violation rule outcomes, and bpa rules list's is its rule-load problems. Enveloping moved those from .diagnostics to .data.diagnostics like every other payload field; the name collision just makes the mistake quiet rather than a missing-key error. Scripts wanting a command's own diagnostics must read .data.diagnostics.

Versioning policy

  • Versions are derived from git tags by MinVer. Tag format: v<major>.<minor>.<patch> (e.g. v1.2.3). Pre-release tags: v1.2.3-alpha.1, v1.2.3-beta.2. Between tags, MinVer auto-increments pre-release identifiers based on commit count since the last tag.
  • Releases are automated (.github/workflows/release-pr.yml, release.yml):
  • Every merge to main publishes a NuGet preview, X.Y.Z-preview.0.N, after the archives pass on native runners. Install with dotnet tool update -g Tomix.Cli --prerelease.
  • A bot keeps one chore(release): prepare X.Y.Z PR open. Merging it tags vX.Y.Z and publishes the GitHub Release and the NuGet package.
  • The release notes are the [Unreleased] section of CHANGELOG.md. While it is empty, no release PR is opened (an open one is closed), even when commits have landed.
  • The bump comes from the PR titles since the last release: feat is minor, everything else patch, ! or BREAKING CHANGE: major. While the major version is 0, breaking changes bump the minor instead.
  • Release candidates and 1.0.0 are manual tags (git tag v0.5.0-rc.1). A new major version waits for approval in the major-release environment.
  • The bump levels:
  • Patch (v1.0.1): bug fixes, no new flags/fields/exit codes.
  • Minor (v1.1.0): new commands, flags, JSON fields — backward-compatible.
  • Major (v2.0.0): removed/renamed a flag, changed a JSON field name or shape, changed an exit code, removed a subcommand.
  • The API surface that major versions protect:
  • JSON output: field names, value types, envelope shape (data, diagnostics).
  • Exit codes: numeric values and their meanings (see CommandOutput).
  • CLI flags: names, aliases, value syntax, default behavior.
  • Subcommand names and noun verb structure.
  • Human-readable output (colors, formatting, prose) is NOT covered by the compatibility guarantee and may change in any version.
  • Maintain a CHANGELOG.md (Keep a Changelog format) in the repo root. Update it in the same PR that ships the change.

Configuration and environment

  • Precedence: flags > env vars > project config > user config > system config.
  • Config files follow platform conventions (XDG on Linux); never modify another tool's config without explicit consent.
  • Env vars are for context that varies per environment/session, not for secrets and not as the primary config store. Respect general-purpose ones: NO_COLOR, TERM, EDITOR, proxy vars, TOMIX_* for app-specific overrides.

Wiring map (where rules live in this repo)

  • Exit codes, format validation, human/JSON dispatch → Output/CommandOutput.cs
  • Color roles, markup helpers, tables → Output/Styling.cs + docs/cli-color-strategy.md
  • Diagnostics to stderr, --error-format → Output/ErrorOutput.cs
  • JSON/CSV contracts → Output/JsonOutput.cs, Output/CsvOutput.cs
  • Recursive global flags → Commands/GlobalOptions.cs
  • Help rendering → Output/HelpRenderer.cs / SpectreHelpAction
  • Version resolution (--version, doctor) → Program.ResolveVersion() reads AssemblyInformationalVersionAttribute set by MinVer
  • Version derivation from git tags → Directory.Build.props (MinVer config)
  • One ICommandModule per command; commands stay thin, no business logic.

Known gaps checklist

  • [x] Grouped sections + Examples blocks in help
  • [x] Help wraps to the terminal, groups long option lists, and fits 80 columns; tx help <command>
  • [x] NO_COLOR / TERM=dumb handling verified or added
  • [x] Confirmations with --yes on rm, replace, deploy, stage commit, mv --save/--revert, bpa run --fix --allow-delete/--revert, and partition-risky refresh; --dry-run on deploy
  • [x] -q/--quiet global flag (suppresses spinners, progress, non-essential output)
  • [x] Empty-state messages with next-step hints on ls/find
  • [x] "Did you mean?" suggestions for unknown subcommands
  • [x] Spinners on slow commands (P0: deploy, bpa, connect, auth; P1: format, save, diff, validate, stage commit; P2: conditional for ls/get/find/deps/load/set/add/mv/rm/replace when remote or --save)
  • [x] refresh command (live per-table steps and rows via XMLA SessionTrace; final summary with partitions and phases)
  • [ ] Ctrl-C handling audit on long-running remote operations
  • [ ] --no-input global flag (covered by --non-interactive; adding a duplicate is confusing)