CLI как инструмент команды: UX текста, exit codes и обработка конфигов
Сделаем CLI “дружелюбным”: читабельные сообщения, help-текст, стабильные exit codes и корректная загрузка конфигов. Разберём сценарии ошибок, которые чаще всего забывают в небольших утилитах.
Содержание
CLI как инструмент команды: UX текста, exit codes и обработка конфигов
Хороший CLI — это не просто “утилита, которая работает”. Это интерфейс между пользователем (и автоматизацией) и вашим инструментом. От качества CLI зависит, будет ли команда доверять вам в проде, как быстро операторы поймут причину сбоя и насколько предсказуемо поведёт себя скрипт в пайплайне.
В этой статье разберём три слоя “дружелюбия” CLI:
- UX текста: как писать сообщения об ошибках и help-текст так, чтобы их читали.
- Exit codes: как сделать поведение стабильным и пригодным для автоматизации.
- Конфиги: как корректно загружать настройки и обрабатывать ошибки конфигурации — в том числе в небольших утилитах, где этому обычно уделяют меньше внимания.
Параллельно будем собирать “чеклист” и типовые схемы, которые часто забывают.
UX текста: сообщения, которые экономят время
Дружелюбность начинается с формулировок
Утилиты обычно пишут так:
- “Error”
- “Something went wrong”
- “Invalid input”
Это почти всегда плохой UX: пользователь не знает, что именно сломалось, где смотреть, что сделать дальше.
Минимальная дисциплина для CLI:
- Сообщение должно быть конкретным. Не “ошибка”, а “не удалось открыть файл”, “невалидное значение для параметра X”.
- Сообщение должно указывать на действие. “Проверьте путь”, “укажите флаг…”, “значение должно быть в диапазоне…”.
- Сообщение должно иметь контекст. Включайте имя команды, параметр, значение (аккуратно, если есть секреты), конкретное исключение или причину.
Пример плохого сообщения:
Error
Пример хорошего:
Не удалось прочитать конфиг: /etc/mytool/config.toml (permission denied). Проверьте права доступа или укажите другой путь через --config.
Обратите внимание: здесь три уровня полезности одновременно: что сломалось, где и что делать.
Разделяйте stderr и stdout
Правило на практике:
- stdout — вывод полезных данных (результаты команды).
- stderr — диагностические сообщения (ошибки, предупреждения, логи уровня debug/trace — по договорённости).
Тогда пайплайны, обработчики и пользователи смогут отделять данные от диагностики:
result="$(mytool export --format json 2> /tmp/err.txt)"
if [ $? -ne 0 ]; then
cat /tmp/err.txt >&2
exit 1
fi
Если диагностика смешивается с stdout, парсинг становится сложным и ломается.
Help-текст: больше, чем “описание команды”
Help — это основной документ CLI. Его читают в двух ситуациях:
- Когда пользователь впервые сталкивается с утилитой.
- Когда пользователь “примерно помнит” синтаксис, но ошибается.
Поэтому help должен отвечать на вопросы:
- Какие команды/подкоманды доступны?
- Какие флаги обязательны?
- Какие значения допустимы?
- Как ведёт себя инструмент по умолчанию?
- Где лежат файлы конфигурации и переменные окружения?
Хороший help обычно включает:
- Короткую сводку: 1–2 предложения.
- Список параметров: для каждого — тип, значение по умолчанию, ограничения.
- Примеры: 2–5 коротких примеров команд (не больше, иначе help превращается в справочник по всему продукту).
- Ссылку на конфиг: где искать конфиг, как его переопределить.
Пример структуры:
mytool [OPTIONS] COMMANDCommands: init, run, statusOptions: --config PATH Путь к конфигу (по умолчанию: ~/.config/mytool/config.toml) --verbose Увеличить логирование --version Показать версию
Три уровня сообщений: info/warn/error
Если ваш CLI содержит сложные сценарии, вводите предсказуемую “иерархию” сообщений:
- info: “делаем X”, “загружено Y записей” — только когда это полезно;
- warn: “параметр игнорируется”, “найден старый формат конфигурации”;
- error: прекращаем выполнение из-за ошибки.
Это может быть как цветной вывод (по желанию), так и простой текст. Главное — единый стиль и смысл.
Exit codes: контракт, который важен даже для маленькой утилиты
Почему exit codes — часть UX
Команды CLI часто вызывают другие программы: cron, CI, bash-скрипты, операторы руками. Для автоматизации exit code — главный контракт.
Если ваш инструмент возвращает “всё подряд одинаково”, то в автоматике нельзя корректно различать “не найден файл” и “внутренняя ошибка”.
Минимальная цель: стабильная семантика exit codes и корректное завершение.
Базовая схема: Unix-подход
Классическая договорённость в Unix-like системах:
0— успех1— общая ошибка2— ошибки использования (невалидные аргументы/команда/флаги)3+— специфические категории
Примените это так, чтобы в будущем было куда расширяться.
Пример:
0— ок2— неверные аргументы пользователя (валидация флагов/схемы CLI)3— конфиг не загружен (файл не найден, не парсится, отсутствуют обязательные поля)4— недоступны внешние ресурсы (сеть, токены, permission)5— логическая ошибка/состояние (например, “команда не применима в текущем режиме”)125— “скрипт/команда не поддерживается” (можно использовать как “internal usage error”, но лучше придерживаться единого подхода)126/127— часто зарезервированы оболочкой; в своих утилитах обычно не нужны255— нераспознанная внутренняя ошибка (если вы используете верхний уровень обработчика)
Exit codes и исключения: не теряйте семантику
Частая ошибка: ловят исключение и делают sys.exit(1) везде. Итог — пользователи видят одинаковый код, а разработчикам нечем различать сценарии.
Лучше создать слой “кодирования ошибки”:
- Ошибки валидации аргументов →
2 - Ошибки конфигурации →
3 - Ошибки внешних систем →
4 - Внутренняя ошибка →
1или255(в зависимости от вашей философии)
Практический пример: Python и ясные exit codes
Ниже пример “скелета” с аккуратной категоризацией. Это не привязано к конкретной библиотеке CLI, но показывает принцип.
import sys
from dataclasses import dataclass
@dataclass
class CliError(Exception):
message: str
code: int = 1
def main(argv: list[str]) -> int:
try:
# 1) Пример: парсинг аргументов
args = parse_args(argv) # может бросить ValueError
# 2) Пример: загрузка конфигов
config = load_config(args.config_path) # может бросить ConfigError
# 3) Пример: выполнение
run_with_config(config, args)
return 0
except ValueError as e:
raise CliError(str(e), code=2)
except ConfigError as e:
raise CliError(f"Не удалось загрузить конфиг: {e}", code=3)
except ExternalResourceError as e:
raise CliError(f"Ошибка доступа к ресурсу: {e}", code=4)
except CliError as e:
print(e.message, file=sys.stderr)
return e.code
except Exception as e:
# Логируйте подробности (traceback) по желанию в verbose/debug
print(f"Внутренняя ошибка: {e.__class__.__name__}", file=sys.stderr)
return 255
# Заглушки, чтобы код был читаемым
class ConfigError(Exception): pass
class ExternalResourceError(Exception): pass
def parse_args(argv):
# В реальном коде: валидация и синтаксис
if "--unknown" in argv:
raise ValueError("Неизвестный параметр --unknown")
return type("Args", (), {"config_path": "~/.config/mytool/config.toml", "verbose": False})()
def load_config(path):
# В реальном коде: чтение и парсинг
if "broken" in path:
raise ConfigError("парсер не смог прочитать формат")
return {"mode": "fast"}
def run_with_config(config, args):
return
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
Ключевые моменты:
- Исключения классифицируются в одном месте.
- Пользователь получает понятное сообщение (в stderr).
- Exit code соответствует категории.
Стратегия для “неожиданного” — без сюрпризов
Если в вашей утилите есть общий catch-all, убедитесь, что он не “прикрывает” ошибки конфигурации и валидации. Иначе вы снова получите код 1 на всё. Нормальная практика: сначала ловить “известные” классы проблем, затем — общий fallback.
Обработка конфигов: от загрузки до валидации
Конфиг — источник большинства пользовательских проблем. Часто это:
- неверный путь,
- неверный формат,
- устаревшая версия схемы,
- конфиг загружен частично,
- конфиг конфликтует с параметрами CLI и переменными окружения.
В небольших утилитах конфигурацию обычно делают “как получится”: прочитал файл, распарсил, использовал. И забывают про ошибки. Мы исправим.
Модель приоритетов: где правда живёт
Сначала зафиксируйте приоритет:
- CLI параметры (флаги команды)
- Переменные окружения
- Конфиг-файл
- Значения по умолчанию
Это самая распространённая и предсказуемая модель. Дальше — документируйте её в help и/или в отдельном разделе конфигурации.
Если этого не сделать, пользователи будут удивляться: “я поставил в конфиге одно, а работает другое”.
Где искать конфиг
Подумайте о том, что должно происходить в типовых случаях:
- Пользователь не указал
--config. - Пользователь указал относительный путь.
- Путь содержит
~. - Утилита должна поддерживать “конфиг по умолчанию”, чтобы не ломать новичков.
Обычно удобно:
- По умолчанию искать в
~/.config/mytool/config.toml(или платформенно корректный XDG). - Разрешить переопределение
--config PATH. - Если конфиг не найден — либо ошибка (если без него нельзя), либо fallback на defaults (если утилита может работать “в базовом режиме”).
Чёткие ошибки конфигурации
Ошибка загрузки конфигурации должна:
- Указывать, какой файл пытались читать.
- Давать причину (not found, permission, parse error).
- Подсказывать дальнейшие действия (“используйте --config”, “проверьте формат TOML”).
Не выдавайте стек-трейс пользователю по умолчанию. Для debug — пусть есть флаг --verbose или --debug, который выводит детали.
Пример сообщений:
- “Конфиг не найден: /home/user/.config/mytool/config.toml. Укажите путь через --config или создайте файл по шаблону.”
- “Не удалось распарсить TOML в /etc/mytool/config.toml: строка 12, колонка 5 — ожидается ‘=’.”
Если вы используете библиотеку парсинга, постарайтесь сохранить позицию ошибки.
Валидация структуры: лучше “раньше”, чем “позже”
Даже если парсинг прошёл, конфиг может быть семантически неверным:
- отсутствует обязательное поле,
- тип не тот,
- значения вне допустимого диапазона,
- “режим” не соответствует схеме.
Хорошая практика: валидация конфигурации выполняется сразу после загрузки, до выполнения логики.
Технически это означает:
- создать схему (валидатор),
- валидировать,
- при ошибке сформировать понятное сообщение для пользователя.
Пример подхода с dataclass и валидацией (идея, не привязка к библиотеке):
from dataclasses import dataclass
@dataclass
class Config:
mode: str
timeout_seconds: int
def validate_config(raw: dict) -> Config:
allowed_modes = {"fast", "safe"}
mode = raw.get("mode")
if mode not in allowed_modes:
raise ConfigError(f"Поле mode должно быть одним из {sorted(allowed_modes)}, получено: {mode!r}")
timeout = raw.get("timeout_seconds")
if not isinstance(timeout, int) or timeout <= 0:
raise ConfigError(f"Поле timeout_seconds должно быть положительным целым, получено: {timeout!r}")
return Config(mode=mode, timeout_seconds=timeout)
Когда валидация добавлена, вы перестаёте ловить “неочевидные падения” в середине выполнения.
Слияние конфигурации: как избежать “магии”
Если вы реализуете слияние конфигурации (config + overrides), важно сделать поведение предсказуемым.
Возможные варианты:
- “CLI override” перезаписывает значения из конфига.
- “config override” перезаписывает defaults.
- частичное слияние словарей (merge) — если схема это позволяет.
Подводный камень: если вы делаете глубинный merge без правил, пользователь может думать, что полностью переопределил блок, а на деле значения остались смешанными.
Решение:
- Сначала определите, где допустим merge, а где нет.
- Для сложных структур используйте явные правила: например, массивы всегда заменяются целиком, а словари мерджатся по ключам.
Конфиг-диагностика: “что именно было загружено”
Когда что-то пошло не так, пользователю полезно понять:
- какой конфиг использован;
- какие значения были применены (хотя бы ключевые);
- откуда взялись переопределения.
Даже если вы не показываете весь конфиг, добавьте в --verbose вывод “профиля”:
- используемый путь,
- версия схемы,
- применённый режим,
- включенные/выключенные ключи.
Это снижает время диагностики в реальной эксплуатации.
Типичные ошибки, которые встречаются почти всегда
1) Нечёткие или отсутствующие категории ошибок
Если ваш CLI всегда возвращает 1, вы лишаете пользователя и автоматизацию возможности правильно реагировать. Минимум нужно различать “usage/arguments” и “runtime/config”.
2) Ошибки валидации, которые проявляются слишком поздно
Если вы проверяете допустимость значений только в момент выполнения, вы получаете:
- непредсказуемые падения,
- длинные “время до ошибки”,
- непонятные сообщения.
Валидация должна быть “в начале”.
3) Конфиг, который “тихо игнорируется”
Классический кейс: пользователь указал --config, но код по ошибке использует дефолтный путь. Или конфиг не загружился, но инструмент продолжил с defaults, “потому что так проще”.
Так делать нельзя: пользователь должен понимать, что конфиг не применился. Если fallback допустим — проговорите это в сообщениях (например, warn).
4) Смешивание диагностики и пользовательского вывода
Если вы печатаете ошибки в stdout, пользователи не смогут легко отделять данные результата от диагностики. Это особенно больно для команд, которые возвращают JSON/таблицы.
5) Help, который не соответствует реальному синтаксису
Help должен обновляться вместе с кодом. Если help врёт, пользователи начинают “догадываться”, что разрушает доверие.
6) Логи/trace по умолчанию
Если инструмент всегда печатает traceback при ошибке, вы даёте пользователю много технического шума и мало практической пользы. Лучше:
- короткое сообщение всегда,
- детали — только в
--verbose/--debug.
Сборка требований: мини-спецификация “дружелюбного CLI”
Сформулируем практический список, который можно использовать как чеклист на ревью:
- Help
- есть короткое описание,
- параметры описаны с типами/значениями,
- есть примеры,
- описаны источники конфигурации и приоритеты.
- Ошибки
- сообщения конкретные и actionable,
- stderr используется для диагностики,
- есть уровни info/warn/error (хотя бы логически).
- Exit codes
- 0 успех,
- 2 — usage/валидация аргументов,
- 3 — конфиг,
- 4 — внешние ресурсы,
- 255 — неожиданные ошибки.
- Конфиги
- фиксированный приоритет CLI/env/config/defaults,
- корректные правила поиска и обработки путей,
- валидация схемы после парсинга,
- в
--verboseпоказывается, что именно было загружено.
- Предсказуемость
- отсутствие “тихих” fallback там, где это не согласовано,
- единообразный формат сообщений по всем командам.
Комментарии
Пока нет комментариев