Input & UX

Detecting CI Environments and Non-Interactive Shells

Make a Python CLI behave when nobody is watching: detect TTYs, CI systems and TERM=dumb, never prompt, swap progress bars for log lines, and use GitHub Actions groups.

Updated

The same command runs in two very different worlds. In a developer's terminal, a person is watching: prompts can ask questions, spinners can spin, progress bars can redraw twenty times a second. In a CI job, a cron entry, a Docker container started without -t, or a pipe into another program, nobody is watching — and those same behaviours become bugs. A prompt waits silently until the job times out an hour later. A progress bar writes thousands of carriage-return redraws into a log that nobody can read. A pager opens and waits for a keypress. This guide builds a small detection layer that tells a CLI which world it is in, and shows how each behaviour should change: prompts that fail fast with the flag to use, progress that becomes timestamped log lines, and — for GitHub Actions specifically — collapsible log groups and error annotations that appear on the pull request. It belongs to the cross-platform terminal compatibility topic.

Prerequisites

  • A Typer or Click CLI with at least one interactive behaviour — a prompt, a progress bar, a spinner or a pager.
  • Rich for progress display (the recipe uses rich.progress.track).

The signals

No single check answers "is a person watching?", but a handful of signals together answer it well:

Signals that you are not talking to a person Environment signals a command line tool can use to detect CI systems and non-interactive sessions, with examples. Signals that you are not talking to a person Signal Set by Implies stdin not a TTY pipes, cron, CI, docker without -t no prompts stdout not a TTY pipes, redirects no colour, no progress CI=true GitHub, GitLab, most CI no prompts, plain output TERM=dumb Emacs shells, some IDEs no cursor movement --no-input / MYTOOL_NO_INPUT the user fail instead of asking Check capabilities (is this a TTY?) first; use CI markers only for the rest.

Capability checks come first. Whether stdin and stdout are terminals (isatty()) is the most reliable signal, because it describes what the process can actually do: without a TTY on stdin there is no one to answer a prompt; without a TTY on stdout, cursor movement and redraws produce garbage. CI markers come second: almost every CI system sets CI=true, and each sets its own variable (GITHUB_ACTIONS, GITLAB_CI, BUILDKITE, JENKINS_URL, TF_BUILD for Azure Pipelines). Some CI runners allocate a pseudo-terminal, so a TTY check alone can be fooled; the marker catches that case. TERM=dumb — set by Emacs shell buffers and some IDE consoles — means no cursor control even when a terminal is present. And an explicit --no-input flag (or environment variable) lets users force non-interactive behaviour anywhere.

The recipe: one detection function

# src/mytool/environment.py
from __future__ import annotations

import os
import sys
from dataclasses import dataclass

CI_VARS = {
    "GITHUB_ACTIONS": "github",
    "GITLAB_CI": "gitlab",
    "BUILDKITE": "buildkite",
    "CIRCLECI": "circleci",
    "JENKINS_URL": "jenkins",
    "TF_BUILD": "azure",
}


@dataclass(frozen=True)
class Session:
    stdin_tty: bool
    stdout_tty: bool
    ci: str | None            # which CI system, if any
    dumb: bool                # TERM=dumb: no cursor movement
    no_input: bool            # the user asked us never to prompt

    @property
    def can_prompt(self) -> bool:
        return self.stdin_tty and self.stdout_tty and not self.no_input and not self.ci

    @property
    def can_animate(self) -> bool:
        return self.stdout_tty and not self.dumb and not self.ci


def detect(no_input: bool = False) -> Session:
    env = os.environ
    ci = next((name for var, name in CI_VARS.items() if env.get(var)), None)
    if ci is None and env.get("CI", "").lower() in ("1", "true", "yes"):
        ci = "generic"
    return Session(
        stdin_tty=sys.stdin.isatty(),
        stdout_tty=sys.stdout.isatty(),
        ci=ci,
        dumb=env.get("TERM") == "dumb",
        no_input=no_input or env.get("MYTOOL_NO_INPUT", "") not in ("", "0"),
    )

The Session object answers two questions the rest of the code actually asks. can_prompt requires terminals on both stdin and stdout, no CI, and no --no-input. can_animate requires a terminal on stdout, a capable TERM, and no CI. Everything else in the CLI consults these two properties rather than inspecting the environment itself, so the policy lives in one place and is trivially testable. Identifying which CI system is running enables CI-specific niceties such as GitHub's log groups.

The recipe: behaviour that adapts

Behaving well when nobody is watching How a command line tool should behave in CI and other non-interactive sessions, and behaviours that cause hangs or unreadable logs. Behaving well when nobody is watching In non-interactive mode Fail fast instead of prompting, naming the flag Log progress as occasional plain lines Keep timestamps, drop spinners Exit codes that say exactly what happened Causes hangs or noise Waiting for input that never comes Progress bars redrawn thousands of times Pagers opened for long output Colour codes in plain-text logs A CI job that hangs until its timeout costs far more than one that fails in a second.
# src/mytool/report.py
from __future__ import annotations

import sys
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import TypeVar

from rich.progress import track

from mytool.environment import Session

T = TypeVar("T")


def progress(items: list[T], label: str, session: Session, every: float = 5.0) -> Iterator[T]:
    """A live bar for people; occasional timestamped lines for logs."""
    if session.can_animate:
        yield from track(items, description=label, transient=True)
        return
    total, last = len(items), 0.0
    print(f"[{time.strftime('%H:%M:%S')}] {label}: {total} items", file=sys.stderr, flush=True)
    for i, item in enumerate(items, 1):
        yield item
        now = time.monotonic()
        if now - last >= every or i == total:
            print(f"[{time.strftime('%H:%M:%S')}] {label}: {i}/{total}", file=sys.stderr, flush=True)
            last = now


@contextmanager
def group(title: str, session: Session) -> Iterator[None]:
    """Collapsible log sections in GitHub Actions; a plain heading elsewhere."""
    if session.ci == "github":
        print(f"::group::{title}", flush=True)
        try:
            yield
        finally:
            print("::endgroup::", flush=True)
    else:
        print(f"== {title}", file=sys.stderr, flush=True)
        yield


def annotate_error(message: str, session: Session, file: str | None = None, line: int | None = None) -> None:
    """Surface an error on the GitHub pull request diff when running in Actions."""
    if session.ci == "github" and file:
        loc = f" file={file}" + (f",line={line}" if line else "")
        print(f"::error{loc}::{message}", flush=True)
    print(f"error: {file + ': ' if file else ''}{message}", file=sys.stderr, flush=True)
# src/mytool/cli.py
from __future__ import annotations

from typing import Annotated

import typer

from mytool.environment import detect
from mytool.report import annotate_error, group, progress

app = typer.Typer()


@app.callback()
def main(
    ctx: typer.Context,
    no_input: Annotated[bool, typer.Option("--no-input", help="Never prompt; fail instead.")] = False,
) -> None:
    """Config tools that behave in CI."""
    ctx.obj = detect(no_input=no_input)


@app.command()
def check(ctx: typer.Context, files: list[str]) -> None:
    """Validate config FILES."""
    session = ctx.obj
    problems = 0
    with group("Validating configuration", session):
        for name in progress(files, "checking", session, every=1.0):
            if name.endswith(".bad"):
                annotate_error("replicas must be at least 1", session, file=name, line=3)
                problems += 1
    raise typer.Exit(1 if problems else 0)


@app.command()
def init(ctx: typer.Context, name: Annotated[str | None, typer.Option()] = None) -> None:
    """Create a config, asking for the name only when a person is present."""
    session = ctx.obj
    if name is None:
        if not session.can_prompt:
            typer.echo("error: --name is required when not running interactively", err=True)
            raise typer.Exit(2)
        name = typer.prompt("Project name")
    typer.echo(f"created {name}.toml", err=True)


if __name__ == "__main__":
    app()

What changes, and why

Prompts fail fast with instructions. When can_prompt is false and a required value is missing, init exits with status 2 and names the flag. A CI job fails in a second with a message that tells the engineer exactly what to add — instead of hanging until its timeout. The same principle for secrets is covered in prompting for passwords securely.

Progress becomes occasional log lines. The progress helper yields items either through Rich's track (a live bar that disappears when done) or through a plain loop that prints a timestamped line at most every few seconds and at the end. Logs stay readable, and still show that the job is alive. Richer progress displays follow the same rule; see adding progress bars and spinners to Python CLIs.

Narration goes to stderr, results to stdout. Progress lines and headings go to stderr, so a job that captures stdout for a report gets only the report — the stream rule from working with stdin, stdout and pipes.

CI systems get their own affordances. GitHub Actions interprets special lines on stdout: ::group::Title and ::endgroup:: fold a section of the log, and ::error file=...,line=...::message creates an annotation that appears next to the offending line in the pull request diff. Other systems have equivalents — GitLab's collapsible section_start markers, Azure Pipelines' ##vso[task.logissue] — and the same group and annotate_error helpers are the place to add them. Outside CI, the helpers fall back to plain text.

The same command, interactive and in CI Terminal output of a sync command showing a live progress bar interactively and plain periodic lines in a CI log. The same command, interactive and in CI bash # interactive terminal syncing ━━━━━━━━━━━━━━━━━━━━━━━━╺━━━━━━━━━━━ 61% 312/512 0:00:07 # CI log (CI=true, stdout not a TTY) [12:00:01] syncing 512 items [12:00:05] 256/512 done [12:00:11] synced 512 items in 10.2s Same work, same information; each form suits where it is read.

UX considerations

  • Never make CI hang. Every interactive behaviour needs a non-interactive path; the worst outcome is a job that waits silently.
  • Say what to change. "--name is required when not running interactively" teaches the fix in the same line as the failure.
  • Keep timestamps in logs. When reading a CI log after the fact, knowing when each step started is how people find what was slow.
  • Disable pagers and colour appropriately. Page only when can_prompt is true; colour follows the rules in respecting NO_COLOR and FORCE_COLOR — many teams set FORCE_COLOR=1 in CI because the log viewer renders it.
  • Let users override detection. --no-input forces non-interactive mode anywhere; for the rare case where detection wrongly disables interactivity, an explicit --interactive flag is a reasonable escape hatch.

Testing the behaviour

CliRunner provides non-TTY streams and an env= mapping, so both worlds are testable. Clear CI variables in a base environment, so tests behave the same on a laptop and in a real CI job:

# tests/test_environment.py
import pytest
from typer.testing import CliRunner

from mytool import environment
from mytool.cli import app

runner = CliRunner()
CLEAN = {"CI": "", "GITHUB_ACTIONS": "", "GITLAB_CI": "", "TERM": "xterm", "MYTOOL_NO_INPUT": ""}


@pytest.mark.parametrize("env, expected", [
    ({"GITHUB_ACTIONS": "true", "CI": "true"}, "github"),
    ({"GITLAB_CI": "true"}, "gitlab"),
    ({"CI": "1"}, "generic"),
    ({}, None),
])
def test_ci_detection(monkeypatch, env, expected):
    for k, v in {**CLEAN, **env}.items():
        monkeypatch.setenv(k, v)
    assert environment.detect().ci == expected


def test_never_prompts_without_a_terminal():
    result = runner.invoke(app, ["init"], env=CLEAN)
    assert result.exit_code == 2 and "--name" in result.output


def test_flags_make_it_work_anywhere():
    assert runner.invoke(app, ["init", "--name", "web"], env={**CLEAN, "CI": "true"}).exit_code == 0


def test_github_actions_groups_and_annotations():
    env = {**CLEAN, "CI": "true", "GITHUB_ACTIONS": "true"}
    result = runner.invoke(app, ["check", "a.toml", "b.bad"], env=env)
    assert result.exit_code == 1
    assert "::group::Validating configuration" in result.output
    assert "::error file=b.bad,line=3::replicas must be at least 1" in result.output
    assert "\r" not in result.output                      # no progress-bar redraws in logs


def test_plain_progress_lines_in_ci():
    result = runner.invoke(app, ["check", "a.toml", "c.toml"], env={**CLEAN, "CI": "true"})
    assert "checking: 2/2" in result.output

The GitHub Actions test checks the exact workflow-command syntax, because a typo there silently turns an annotation into an ordinary log line. The "\r" not in result.output assertion guards against progress-bar redraws leaking into logs. For the interactive path, patch detect to return a Session with terminals and feed answers with input=.

Conclusion

A CLI that behaves well when nobody is watching needs one small detection function and a handful of adaptations. Check for terminals on stdin and stdout first, then CI markers and TERM=dumb, and honour --no-input. Turn the result into two decisions — can we prompt, can we animate — and let every prompt, progress bar and pager consult them. Fail fast with instructions instead of prompting, swap animations for timestamped log lines, and use CI-specific features such as GitHub's groups and annotations when they are available. The same command then serves a developer at a terminal and a pipeline at 3 a.m.

Frequently asked questions

Is checking CI=true alone enough?

It is a good hint but not a capability check: some developers set CI locally, and some non-CI automation does not set it. Combine it with isatty() checks, which describe what the process can actually do.

Why do some CI jobs report a TTY?

Some runners, and docker run -t inside a job, allocate a pseudo-terminal so that tools produce colourful output. That is exactly why the CI marker is checked in addition to isatty() — animations and prompts are still wrong even with a pseudo-terminal present.

How do I make CI output collapsible outside GitHub?

GitLab CI uses \e[0Ksection_start:<timestamp>:<name>\r\e[0K<title> and a matching section_end line; Azure Pipelines uses ##[group] and ##[endgroup]. Detecting which system is running, as detect() does, lets the group helper emit the right form.

Should the CLI change its exit codes in CI?

No — exit codes should mean the same everywhere. What changes in CI is presentation. Make sure the codes are meaningful to begin with, as described in choosing exit codes for CLI tools.