CLI с интерактивом: как сделать команды “как приложение”, а не как утилиту
Поясним, как добавлять подтверждения, прогресс, выбор вариантов, нормальную обработку Ctrl+C и понятный UX. Рассмотрим структуру команды и контракты аргументов.
Содержание
CLI с интерактивом: как сделать команды “как приложение”, а не как утилиту
Интерактивный CLI давно перестал быть нишей. Пользователи ожидают от команд не только “выполни действие и верни код возврата”, но и нормального UX: подтверждений до опасных операций, отображения прогресса, выбора вариантов вместо ручного ввода сложных параметров, корректной реакции на Ctrl+C, понятных ошибок и даже “сценариев” вместо линейных консольных логов.
Проблема в том, что многие CLI продолжают проектировать как утилиты: аргументы — это строка кода, а взаимодействие — “прочитайте README”. В результате команда ведёт себя непредсказуемо, при отмене оставляет систему в неопределённом состоянии, а вывод превращается в нечитаемую ленту текста.
Ниже — практический разбор того, как проектировать CLI с интерактивом так, чтобы он ощущался как приложение: с контрактами аргументов, управляемым сценарием выполнения и предсказуемыми состояниями.
Что значит “команда как приложение”: UX и поведение, а не только интерфейс
“Как приложение” в CLI обычно выражается в нескольких свойствах:
Предсказуемые режимы работы
Команда должна работать одинаково предсказуемо в:
- неинтерактивном режиме (например, в CI): всё управляется аргументами/флагами, интерактив не включается;
- интерактивном режиме (в терминале): пользователь получает вопросы/меню, подтверждения, прогресс.
Эта разница должна быть прозрачной. Частая ошибка — “команда иногда спрашивает, а иногда молчит”, причём условие скрыто (например, из-за наличия TTY).
Контракты аргументов вместо “а что вы имели в виду”
У команды должен быть явно определённый контракт:
- что обязательно,
- что опционально,
- какие значения допустимы,
- какие комбинации запрещены,
- как разрешаются конфликты (приоритеты).
Интерактив не должен ломать контракт. Напротив: интерактив может заполнить пробелы, но не должен “магически” игнорировать указанные пользователем значения.
Состояния и обработка отмены
У приложения есть состояния (“проверяем…”, “готовим…”, “выполняем…”, “завершаем…”) и обработка отмены. В CLI то же самое: Ctrl+C не должен приводить к полуработе без подсказок. В идеале отмена должна быть “безопасной”:
- либо откат до последнего шага,
- либо явное предупреждение, что часть операций не отменяется.
Структура команды: от модели действий до аргументов
Чтобы CLI ощущался как приложение, начните проектирование не с флагов, а с модели выполнения.
Модель сценария: шаги и переходы
Сведите команду к “машине состояний”:
- Сбор входных данных
Из аргументов + опционально из интерактива (если разрешён). - Валидация
Проверки формата, прав, зависимостей. - Планирование
Подготовка “что будет сделано” (полезно для подтверждения). - Исполнение
Реальные операции. - Завершение
Суммарный результат, ссылки на артефакты, советы следующего шага.
Такой подход упрощает и UX, и тестируемость: вы можете проверять отдельные шаги без запуска реальных действий.
Контракты аргументов: необходимые, опциональные, взаимоисключающие
Хороший CLI описывает правила на уровне интерфейса. Например:
--force/--yes— подтвердить опасные действия без вопросов.--dry-run— показать план без изменений.--format json— вывести машинно-читаемый результат (и тогда интерактив отключается).--timeout 30s— ограничение ожидания.--path/--remote— источники/цели.
Дальше правила:
- Если
--dry-runвключён —--yesне нужен, но подтверждение может показывать, что изменения не выполнятся. - Если
--format json— интерактив не включается: JSON должен быть детерминированным. --watchнесовместим с--timeout(например, потому что watch сам “живёт”).--targetобязателен, если выбран--mode deploy.
Это всё лучше фиксировать в коде как проверки до “исполнения”, а интерактив — использовать только для заполнения отсутствующих значений.
Интерактив: как добавлять подтверждения, не ломая автоматизацию
“Подтвердить” ≠ “спросить любой ценой”
Подтверждения нужны в двух случаях:
- Опасные действия (удаление, перезапись, миграции, изменение конфигурации).
- Неполная ясность (например, пользователь не указал цель или режим).
Но важно: подтверждение должно быть обусловлено риском и режимом запуска.
Пример UX:
- в TTY и без
--yes— спрашиваем; - в CI (нет TTY) и нет
--yes— команда завершается с понятной ошибкой вида: “Требуется --yes для удаления”; - при
--dry-run— подтверждение не требуется, но можно показать план.
Рекомендованные варианты подтверждения
Пользовательских “кнопок” обычно достаточно:
y/N(да по умолчанию только если безопасно)yes/noдля более явной верификации- ввод дополнительных параметров (“ввести ‘delete’ чтобы подтвердить” — для особо опасных операций)
Из практики: если команда действительно разрушительная, простого y может быть недостаточно. Но не перегибайте — иначе люди будут использовать --force всегда, и смысл подтверждений исчезнет.
Реализация подтверждения: состояние и отмена
Подтверждение — это часть сценария, поэтому оно должно:
- корректно обрабатывать
Ctrl+C(выход без продолжения), - возвращать согласованный результат (“подтверждено/отменено”),
- быть консистентным в разных ветках.
Ниже пример для Python с использованием стандартного signal и input(); логика показана схематично, чтобы её можно было перенести на вашу платформу.
import sys
import signal
class Cancelled(Exception):
pass
def install_sigint_handler():
def handler(signum, frame):
raise Cancelled()
signal.signal(signal.SIGINT, handler)
def confirm(prompt: str, default_no: bool = True) -> bool:
"""
Возвращает True если пользователь подтвердил, иначе False.
"""
if default_no:
suffix = "[y/N]"
else:
suffix = "[Y/n]"
while True:
try:
answer = input(f"{prompt} {suffix} ").strip().lower()
except EOFError:
return False # нечего подтверждать
except Cancelled:
# Важно: не делаем вид, что подтверждение было.
raise
if not answer:
return not default_no # если пусто — возвращаем дефолт
if answer in {"y", "yes"}:
return True
if answer in {"n", "no"}:
return False
print("Пожалуйста, ответьте 'y' или 'n'.", file=sys.stderr)
def main():
install_sigint_handler()
# ... где-то в сценарии:
dangerous = True
try:
if dangerous:
ok = confirm("Вы уверены, что хотите удалить данные?", default_no=True)
if not ok:
print("Отменено пользователем.")
return 1
print("Действие выполнено.")
return 0
except Cancelled:
print("\nОперация отменена (Ctrl+C).")
return 130
if __name__ == "__main__":
raise SystemExit(main())
Обратите внимание на два момента:
- при
Ctrl+Cвозвращаем код 130 — это распространённая договорённость Unix-подобных систем для “terminated by Ctrl+C”. - подтверждение не пытается “поймать и продолжить”: отмена должна прерывать сценарий.
Автоматические сценарии: --yes и “разумный дефолт”
Если вы вводите интерактив, нужны режимы, которые гарантируют детерминированность:
--yes(или--no-interaction) чтобы в автоматизации не происходило вопросов;- понятная ошибка, если пользователь запустил интерактивную ветку в неинтерактивном окружении.
Критически важно: код возврата должен соответствовать причине:
- 0 — успешно,
- 1 — пользователь отменил или невалидный ввод (иногда выделяют разные коды),
- 2 — синтаксическая ошибка аргументов,
- 130 —
Ctrl+C.
Прогресс: как показывать его правильно и не раздражать
Прогресс должен соответствовать длительности операции
Прогресс полезен только когда:
- операция реально занимает время,
- есть этапы или хотя бы можно оценить завершённость.
Если вы показываете “спиннер” без содержания — это скорее вред. Пользователь видит “что-то происходит”, но не понимает “что именно” и “сколько осталось”.
Три уровня информативности прогресса
- Этапы: “Сканируем…”, “Проверяем доступ…”, “Загружаем…”
- Прогресс по итерациям: индикатор “12/200 файлов”.
- Процент: только если можно оценить общее количество/объём.
В хороших CLI обычно комбинация: сначала показывают текущий этап, а затем добавляют процент/счётчик.
Прогресс и логирование: разделяйте каналы
Одна из частых проблем — прогресс “пишет” в stdout, а ошибки — туда же. В результате:
- в пайпе/redirect теряется читаемость,
- JSON-вывод смешивается со строками прогресса.
Решение: прогресс — в stdout только если формат не машинный, а ошибки — в stderr. Многие библиотеки (например, tqdm в Python) имеют параметры, куда писать. В любом случае закрепите правило в дизайне.
Отмена во время прогресса
Во время длительной операции важно:
- корректно реагировать на
Ctrl+C, - завершать текущий шаг безопасно,
- при необходимости делать “частичный rollback”.
На практике часто выбирают компромисс: прерывают выполнение и пишут итог:
- “прервано на шаге X; частично выполнено: Y; пропущено: Z”.
Это намного лучше, чем молча оставить следы.
Выбор вариантов: когда интерактив улучшает интерфейс, а не заменяет аргументы
Меню вместо “введите сложное значение”
Интерактив удобен там, где пользователь:
- не знает точных значений (например,
--profile prod-eu-west), - хочет выбрать из существующих (список проектов/контейнеров/шаблонов),
- совершает действие один раз, а не в массовом режиме.
В таком случае команда может:
- показать список,
- дать поиск,
- подсветить последствия выбора (“будут затронуты…”, “используется…”).
Но при этом команда должна поддерживать прямые аргументы, если пользователь знает, что делает.
Контракт выбора: значение, идентификатор и отображение
Важно различать:
- идентификатор (что реально нужно для выполнения),
- отображаемая метка (что видит пользователь).
Пример: выбор “Project: Payments” в меню должен приводить к внутреннему --project-id=123. Тогда при изменении названия “Payments” сценарий не ломается.
Нормализация ввода и обработка ошибок
Меню должно уметь:
- принимать ввод “число”,
- принимать “ключевые слова”,
- повторять вопрос при ошибке,
- отменяться корректно по
Ctrl+C.
Типичная ошибка — позволить пользователю ввести свободный текст, не проверив его на допустимость, а потом “падать” на позже.
Если вы строите UX как приложение, то проверка должна происходить до исполнения.
Нормальная обработка Ctrl+C: коды возврата и безопасность состояния
Что должен делать CLI при Ctrl+C
Минимальные ожидания:
- Остановить текущий шаг.
- Не продолжать выполнение “в фоне”.
- Дать пользователю короткое сообщение.
- Возвратить корректный exit code (обычно 130).
Если операция оставляет следы — сообщить:
- что сделано,
- что не сделано,
- что можно безопасно повторить,
- что требует ручного вмешательства.
Чем опасен “просто поймать Ctrl+C и игнорировать”
Если вы перехватили SIGINT, но не остановили исполняющую часть, вы создаёте иллюзию отмены. Пользователь повторяет команду, а старая операция продолжает изменять систему.
Правило: если отмена подтверждена сигналом — сценарий должен прекращаться детерминированно.
Отдельно: “Ctrl+C во время подтверждения”
Если Ctrl+C пришёл в момент, когда команда спрашивает “удалить?” — это не “ошибка”, а отмена. Сообщение можно сделать нейтральным:
- “Отменено пользователем.”
- exit code — 130.
Практика: фазируйте отмену
Обычно помогают два уровня:
- на “планировании/валидации” отмена просто прекращает сценарий;
- на “исполнении” отмена либо прерывает шаг, либо переводит операцию в безопасный режим завершения.
Детерминированность и режимы: интерактив должен отключаться там, где его нельзя включать
Когда интерактив недопустим
- stdin/tty отсутствует (например, в пайпе или в CI).
- пользователь выбрал
--format jsonили--machine. - вывод используется как вход в другой инструмент.
- команда работает как часть пайплайна.
В этих случаях правильное поведение — не “угадывать”, а требовать явные параметры. Вместо “мы что-то спросили, но input недоступен” — сообщение о том, что нужно добавить флаг.
Пример текста ошибки:
- “Команда требует подтверждения. Запустите с
--yesили--dry-run, либо укажите--force.”
Разрешайте интерактив как функцию окружения, а не “магии”
Некоторые CLI включают интерактив только при наличии TTY. Это нормальная эвристика, но важно:
- явно документировать это в help,
- обеспечить предсказуемость (не менять поведение внезапно при разных терминалах).
Параметр “force/yes” должен быть последним словом
Пользователь явно указал --yes — значит вопросов не будет. Это и есть “контракт”.
Контракты аргументов и командная спецификация: как не превратить CLI в хаос
Один из самых недооценённых аспектов — “контракт” не как документ, а как механика.
Утверждайте инварианты до исполнения
Проверяйте заранее:
- что комбинации аргументов допустимы,
- что путь/ресурс существует (или создаётся),
- что в режиме
--dry-runне происходит побочных эффектов, - что подтверждения выполняются только для определённых операций.
Валидация до интерактива
Если аргументы заведомо противоречат друг другу — интерактив не должен вступать в игру. Например:
--mode deleteс--targetбез указания обязательных параметров — это можно уточнить интерактивом;--format jsonс необходимостью интерактива — это должно быть ошибкой, потому что формат требует детерминированности.
Проработайте help и сообщения об ошибках
Помните, что пользователь часто читает не README, а “ошибку из CLI”. Поэтому:
- сообщение должно говорить “что не так” и “что сделать”,
- не должно быть внутреннего стектрейса (кроме debug-режима).
Пример: проектирование команды с интерактивом и понятными ветками
Рассмотрим сценарий: cli delete — удаление набора ресурсов. У него есть:
- обязательный
--resource(если не задан — запросить), - подтверждение перед удалением (если не задан
--yes), - прогресс удаления,
- корректная отмена.
Ниже — “каркас”, который демонстрирует общий паттерн. Это не привязано к конкретной библиотеке, но отражает архитектуру.
import sys
import time
import signal
from dataclasses import dataclass
from typing import Optional, List
class Cancelled(Exception):
pass
def install_sigint_handler():
def handler(signum, frame):
raise Cancelled()
signal.signal(signal.SIGINT, handler)
@dataclass
class DeleteOptions:
resource: Optional[str]
yes: bool
dry_run: bool
interactive_allowed: bool
def parse_args(argv: List[str]) -> DeleteOptions:
"""
Здесь упрощённый парсер. В реальном проекте используйте argparse/typer/click и т.п.
"""
# Псевдо-парсинг для иллюстрации:
resource = None
yes = False
dry_run = False
for a in argv:
if a.startswith("--resource="):
resource = a.split("=", 1)[1]
elif a == "--yes":
yes = True
elif a == "--dry-run":
dry_run = True
interactive_allowed = sys.stdin.isatty() and sys.stdout.isatty()
return DeleteOptions(resource=resource, yes=yes, dry_run=dry_run, interactive_allowed=interactive_allowed)
def prompt_resource() -> str:
while True:
try:
s = input
Комментарии
Пока нет комментариев