Files
tailscale/docs/cli.md
James Tucker 045c979802 docs: add CLI evolution guidelines
The guidelines here provide a written version of common guidance around
our CLI evolution that designers/implementors should consider as they
propose/implement new or evolving CLI surfaces.

Updates #engdocs

Change-Id: Idcbc0900a4fda98bd2b29ac8bbc26dc1cb1be48f
Signed-off-by: James Tucker <james@tailscale.com>
2026-07-10 14:55:33 -07:00

299 lines
14 KiB
Markdown

# Tailscale CLI evolution guidelines
This document offers guidance for evolving the Tailscale core command-line
interface(s), principally `tailscale` (and, where relevant, `tailscaled`). It
is intentionally lightweight. It describes the *spirit* we want to preserve as
the CLI grows, not a rigid specification. As with all style guides, some of it
is subjective and exists mainly to codify existing conventions and promote
consistency; other parts have stronger reasoning, such as compatibility,
accessibility, or long-term maintenance burden.
When a guideline here conflicts with good judgement in a specific case,
prefer good judgement, but raise the conflict so the guidance can be improved.
These are defaults and tie-breakers, not laws.
## Collaboration and design review
CLI design is a collaborative process, and these guidelines are best applied in
conversation rather than in isolation.
Tailscalars should seek collaboration in the **#cli** channel in Slack for
design reviews early, before significant implementation work, while a design is
still easy to change.
Open-source contributors should anticipate feedback on CLI changes and expect to
be asked to approach design review collaboratively and thoughtfully. CLI surface
is long-lived and hard to change once shipped, so a proposed change may go
through more discussion than a typical bug fix. This is not a barrier; it is how
we keep the CLI coherent. Engaging openly with that review process is the fastest
path to getting a change merged.
## Influences and non-goals
Some good concepts and vocabulary here are drawn from the Fuchsia CLI
guidelines:
* https://fuchsia.dev/fuchsia-src/development/api/cli
* https://fuchsia.dev/fuchsia-src/development/api/cli_help
We borrow ideas, not rules. Those documents are more prescriptive than we want
to be, and several of their specifics do not apply to us. In particular:
* We are **not** adopting their `--help` output format. Our help output has its
own history and conventions, and we will not switch formats arbitrarily.
* We have many **pre-existing** commands and behaviors that predate any of
this guidance. We accept them as-is. This document is about how we *grow*
from here, not a mandate to retrofit existing surfaces.
## Guiding principles
The Tailscale CLI is one of the most visible parts of the product. It is used
interactively by humans, embedded in scripts and pipelines, read by screen
readers, and depended on for automation. Small decisions compound. We would
rather do the harder work of getting a surface right than take the easier
short-term path of adding one more toggle.
### Keep overall configurability low
We aim to keep the overall configurability of the Tailscale client *low*. It is
strongly preferred to identify broadly common solutions to heterogeneous user
challenges and apply them holistically, rather than exposing large configuration
surfaces.
Wide configuration surfaces are deceptively expensive: they invite users to
spend effort tuning them, they increase the support load, they multiply the
combinations we must test and reason about, and they tend to ossify into
compatibility obligations. Prefer doing the hard work of getting the default
right over the easy "now" work of adding another option.
This is a default bias, not an absolute prohibition. When a setting is genuinely
necessary, add it deliberately and in the right place (see below).
## Command structure
### Top-level commands
The top-level command surface (`tailscale <command>`) is precious. Every
addition there is effectively permanent and competes for the user's attention
and memory. We want it to grow slowly.
Some commands earn a place at the top level because they are used so frequently
that the brevity is worth it:
* `tailscale set` and `tailscale get` are fine as independent top-level
commands, in large part *because their usage is so frequent*. The cost of an
extra level of nesting would be paid constantly.
### Prefer `tailscale <noun> <verb>` for features
For new "feature"-oriented designs, we will generally prefer a
`tailscale <noun> <verb>` structure (for example `tailscale <feature> status`,
`tailscale <feature> enable`). This lets a feature's surface grow over time
without growing the top-level CLI surface too quickly. New verbs and options
live under the noun, where they are discoverable in context and do not crowd the
root.
This is a guideline, not a cage. Do not force a design into
`<noun> <verb>` if there is wide consensus that the resulting structure is
awkward. Naturalness and usability win over structural purity.
### Noun naming
Prefer **singular** nouns (`tailscale cert`, `tailscale ip`) unless a singular
form is strongly unnatural for the concept. Consistency in number makes commands
easier to predict and remember.
### `tailscale up` is closed for extension
`tailscale up` is now effectively **closed for extension**. New configuration
features should prefer to go into `set` rather than `up`.
The reason is historical and concrete: `up` has the long-standing problem that
its flags describe a desired *complete* state, so a later invocation must repeat
(or carefully manage) all of the earlier flags or risk resetting them. `set` was
introduced precisely to allow incremental, additive configuration changes
without that footgun. New knobs belong there.
### Extend existing surfaces when it fits the spirit
Existing surfaces should be extended where appropriate, provided the extension
does not substantially change the *spirit* of the surface.
For example, adding new target query types to `tailscale ip` is a good fit where
those targets are sufficiently visible to clients: it is the same operation
("resolve a thing to an IP") applied to more inputs. That is extension in the
spirit of the command, not scope creep.
### Avoid arbitrary growth in complexity
Resist the temptation to grow a command to match the full surface of some
external tool it resembles. For example, `tailscale ssh` does **not** intend to
reimplement the full flag surface of `ssh` over time. Doing so would be an
enormous and open-ended maintenance burden, and `ssh` itself is essentially
ubiquitously available and already serves that purpose. A Tailscale command
should do its job well, not absorb an entire adjacent ecosystem.
## Debug commands
Heavily used `debug` subcommands should, over time, graduate into proper
features of the main program when they prove broadly useful.
`debug` subcommands are explicitly **not** subject to our usual compatibility
promises. They are best-effort and may change or be removed. Treat them as a
staging ground and a power-user/diagnostic surface, not as a stable API. If a
debug command becomes load-bearing for users, that is a signal to promote and
stabilize it deliberately rather than to quietly grant it stability by neglect.
## Configuration: where settings live
### Profile-local preferences are the default home
Most configuration should be **profile-local** preferences, set and read via
`set`/`get`, and modifiable at runtime. This is where the overwhelming majority
of user-facing configuration belongs.
### `tailscaled` flags are a last resort
Daemon (`tailscaled`) command-line flags should ideally be added **only** when a
setting is truly *process-global* and *immutable after launch*. If something can
reasonably be a profile-local preference that is changeable via `set`/`get`, it
should be, not a daemon flag.
Daemon flags are sticky and global; they are the wrong place for anything a user
might want to change per-profile or at runtime.
Daemon flags are also, in practice, **almost Linux-only**. Most GUI platforms
(for example macOS, Windows, and mobile) make it very difficult for a user to
adjust how the tailscale backend (e.g. `tailscaled`) is launched, so a setting
that lives only in a daemon flag is effectively unreachable for most of our
users. This is another reason to prefer profile-local preferences via
`set`/`get`, which work uniformly across platforms.
### Prefer preferences over environment variables
For both `tailscale` and `tailscaled` configuration surfaces, prefer
preferences (`set`/`get`) over environment variables. Environment variables are
hard to discover, hard to deprecate, and tend to leak into permanence.
### `TS_DEBUG` environment knobs
Add `TS_DEBUG`-style env knobs freely as development work requires them. These
are explicitly **for development only**. They are not intended for end users and
should not be documented or encouraged for public use.
A cautionary note: we already have cases of debug env knobs that became
load-bearing for someone and which we may never be able to remove. That is an
undesirable outcome. Keeping these clearly internal and undocumented is what
preserves our freedom to change or delete them.
## Output and streams
### `--json` and stable machine output
Commands should offer a **stable** JSON format behind a `--json` flag. Once a
feature is generally available (GA), that JSON format is maintained in a
backward-compatible way as much as possible, in keeping with Tailscale's broad
compatibility policy.
Backward compatibility here means existing fields keep their meaning. **Adding
new fields is allowed** and expected; consumers must tolerate unknown fields.
### Human-readable (non-JSON) output
Non-JSON command output is the default and should be optimized, first and
foremost, for *easy reading by sighted humans*, and, where possible, to *read
well with a screen reader*. Concretely:
* Avoid decorative Unicode output. Prefer plain ASCII.
* Convey information in the **text**, not only via color or position. Do not
rely on color to communicate meaning (see "Color and decoration" below).
* Keep output uncluttered and scannable.
The `grep`/`awk`-ability of stdout output should be considered and maintained on
a **best-effort** basis, but it is **not** a hard compatibility guarantee when
readability or usability would suffer substantially as a result.
In practice this means being conservative with the *shape* of stable output. For
example:
* Adding a **new column** in the middle of already-stable tabular output should
only be done after strongly weighing the tradeoffs, and most often should not
be done, because it breaks naive positional parsers.
* Adding **new in-column behavior** can be done with care. A good example: the
idle/connected/DERP/relay status field was extended to describe relays
*within the existing column* rather than by adding a new column. That
technically introduced new parsable content into the column (so the strictest
parsers might notice), but it avoided breaking the common case, and *not*
conveying the information would have been too large a usability and
readability loss.
The principle: prefer changes that preserve the common parser and the human
reader simultaneously; when forced to choose, protect the human reader, and make
machine consumers use `--json`.
### stdout vs. stderr
**stderr is the standard *information* stream.** It is not only for errors. It
carries all informational, diagnostic, and progress output that is not the
direct intended output of the command.
**stdout** should contain (within reason) the command's *intended* output — the
thing the user actually asked for — or nothing. Everything else (status notes,
progress, warnings, diagnostics) goes to stderr.
This separation is what makes the CLI usable in pipelines: a user can pipe
stdout into another tool without having diagnostic chatter corrupt the data
stream.
A couple of implications worth calling out:
* `--help` is *intended* output: when the user asks for help, help text should
go to **stdout**.
* An *error* about bad flags that happens to print usage as part of a
diagnostic message is diagnostic output and should go to **stderr**, not
stdout.
(At the time of writing, our handling of this distinction for help output is
an existing bug. New code should follow the correct behavior, and we should
fix the existing behavior over time.)
### TTY-dependent behavior
Altering behavior based on whether stdout is a TTY is sometimes genuinely
helpful (for example, suppressing a spinner when not interactive). Use it
sparingly.
Major changes in behavior or output based on TTY detection are an impediment to
use and should not be added arbitrarily. Requiring something like
`tailscale foo | less` in order to *discover* a behavior should not become a
standard pattern: it is a usability issue that excludes many users, including
those using screen readers and automation.
### Color and decoration
Support for, and further discussion of, colored or heavily decorated output is
**deferred at this time** and should not be added until guidance is clarified.
The primary concern is accessibility: colored and decorated output is often
hostile to screen-reader users, and information encoded only in color is
invisible to many people. Until we have clear guidance, keep output plain and
ensure all meaning is present in the text itself.
## Summary checklist
When adding or changing CLI surface, ask:
* Tailscalars can raise a thread in #cli
* Could this be a profile-local preference via `set`/`get` instead of a new
top-level command, a `up` flag, a daemon flag, or an env var? Usually it
should be.
* Does it fit a `tailscale <noun> <verb>` shape without being awkward?
* Is the noun singular (unless that's strongly unnatural)?
* Does it provide stable `--json` output, and is the human output plain,
screen-reader-friendly, and free of color-only meaning?
* Does intended output go to stdout and everything else to stderr?
* Are you extending an existing surface in its spirit, rather than growing
complexity arbitrarily?
* If it's a debug command, is it clearly best-effort and not silently becoming
a stable dependency?