Олег Марков
Создание и использование CLI инструментов cli tools
Введение
CLI инструменты (cli-tools, command line interface tools) — это программы, которые вы запускаете из командной строки и управляете ими через текстовые команды и параметры. Вы постоянно встречаете их в работе: git, docker, kubectl, go, npm, make, curl и десятки других утилит.
CLI-инструменты важны по нескольким причинам:
- позволяют автоматизировать однотипные действия;
- удобно интегрируются в CI/CD;
- не требуют сложного интерфейса и легко поддерживаются;
- хорошо комбинируются между собой через пайпы и скрипты.
В этой статье вы разберетесь, как устроены CLI-инструменты, какие возможности они обычно предоставляют, как проектировать свой CLI так, чтобы им было удобно пользоваться, и какие есть практические подходы к реализации на примере нескольких технологий.
Давайте шаг за шагом посмотрим, как создать осмысленный, удобный и расширяемый CLI-инструмент.
Что делает CLI инструмент удобным
Основные свойства хорошего CLI
Удобный CLI-инструмент обычно:
- Предсказуемо ведет себя при одинаковых входных данных.
- Имеет понятные команды и флаги.
- Выводит внятные сообщения об ошибках.
- Поддерживает
--helpи документацию. - Легко встраивается в скрипты (читаемый stdout, коды возврата).
- Имеет стабильный интерфейс, чтобы не ломать существующие сценарии.
Смотрите, я покажу вам на простом примере. Представим утилиту imgtool для работы с изображениями:
imgtool resize --width 800 input.jpg -o output.jpgimgtool info input.jpgimgtool convert input.png --format jpeg -o output.jpg
Команды (resize, info, convert) читаются как действия, а флаги (--width, --format, -o) уточняют поведение. Такой стиль хорошо воспринимается и пользователем, и в скриптах.
Структура типичного CLI
Обычно CLI-инструмент работает по следующей схеме:
- Разбор аргументов командной строки.
- Валидация значений и проверка совместимости флагов.
- Выполнение основной логики.
- Формирование результата:
- полезные данные в stdout;
- ошибки и подсказки в stderr;
- код возврата (0 — успех, не 0 — ошибка).
Давайте разберемся подробно, как все это организовать.
Проектирование интерфейса CLI
Команды и подкоманды
У многих CLI есть подкоманды. Например:
git commitdocker runkubectl apply
Такой подход помогает разделять ответственность. Вместо одной огромной команды с десятками флагов вы делите функциональность на группы.
Вы можете спроектировать свое cli-tool примерно так:
app init— инициализация проекта;app build— сборка;app deploy— деплой;app config set— установка конфигурации;app config get— чтение конфигурации.
Обратите внимание, как подкоманда config сама имеет подкоманды set и get. Это делает интерфейс логичным и предсказуемым.
Именование флагов
Рекомендации по флагам:
- короткие:
-v,-o,-f— удобно набирать часто; - длинные:
--verbose,--output,--force— хорошо читаются и понятны.
Частая схема:
-vдля--verbose;-hдля--help;-oдля--output.
Некоторые флаги логические (включают режим), например:
--force— игнорировать предупреждения;--dry-run— ничего не менять, только показать план.
Другие принимают значения:
--config path/to/file.yaml;--format json;--timeout 30s.
Вход и выход: stdout, stderr, exit codes
Чтобы ваш CLI хорошо жил в экосистеме UNIX-подобных систем, важно:
- отправлять основной результат в stdout;
- отправлять ошибки и служебную информацию в stderr;
- использовать коды возврата.
Пример хорошего поведения:
0— успех;1— общая ошибка;2— ошибка валидации аргументов;3+— другие типы ошибок по вашему выбору.
Теперь вы увидите, как это выглядит в коде на примере псевдокода:
#!/usr/bin/env bash
input="$1" # Аргумент, который передали первым
if [ -z "$input" ]; then
echo "Ошибка - не указан входной файл" >&2 # Сообщение в stderr
exit 2 # Код ошибки валидации
fi
# Здесь мы делаем какую-то обработку
echo "Результат обработки файла $input" # Выводим результат в stdout
exit 0 # Успех
Такой подход позволяет использовать ваш инструмент в пайпах:
toolA input.txt | toolB --format json | toolC > final.txt
Разбор аргументов: базовые подходы
Теперь давайте перейдем к конкретным способам реализации. Я покажу вам несколько распространенных подходов: на чистом языке, через стандартные библиотеки и с помощью специализированных фреймворков.
CLI на bash: минимум зависимостей
Для очень простых cli-tools часто используют shell-скрипты. Например, вспомогательная утилита для разработки.
Пример скрипта с разбором флагов:
#!/usr/bin/env bash
# Здесь мы включаем "строгий" режим bash
set -euo pipefail
show_help() {
# Здесь мы выводим краткую справку по использованию
echo "Usage: devtool [command] [options]"
echo
echo "Commands:"
echo " build Собрать проект"
echo " test Запустить тесты"
echo
echo "Options:"
echo " -h, --help Показать эту справку"
}
command="${1:-}" # Здесь берем первую позиционную переменную, если нет — пустая строка
case "$command" in
build)
# Здесь мы вызываем команду сборки проекта
echo "Сборка проекта..."
npm run build
;;
test)
# Здесь мы запускаем тесты
echo "Запуск тестов..."
npm test
;;
-h|--help|"")
# Здесь мы показываем справку, если команда не указана или запрошен help
show_help
;;
*)
# Здесь мы обрабатываем неизвестную команду
echo "Неизвестная команда $command" >&2
show_help
exit 1
;;
esac
Скрипты хорошо подходят:
- для внутренних инструментов команды;
- для оберток над уже существующими утилитами;
- когда нет необходимости в сложной логике.
Но по мере роста функциональности удобнее перейти к полноценному языку (Go, Python, Node.js и др.) и библиотекам для CLI.
CLI на Go: пример с библиотекой flag
Go часто выбирают для cli-tools из-за:
- простого деплоя (один бинарник);
- скорости запуска;
- статической типизации;
- богатой экосистемы.
Сначала давайте посмотрим на стандартную библиотеку flag. Она подходит для простых утилит без подкоманд.
package main
import (
"flag"
"fmt"
"os"
)
func main() {
// Здесь мы объявляем флаги
var verbose bool
var output string
flag.BoolVar(&verbose, "verbose", false, "включить подробный вывод")
flag.BoolVar(&verbose, "v", false, "включить подробный вывод (короткий флаг)")
flag.StringVar(&output, "output", "result.txt", "путь к файлу вывода")
flag.StringVar(&output, "o", "result.txt", "путь к файлу вывода (короткий флаг)")
// Здесь мы разбираем аргументы командной строки
flag.Parse()
// Здесь мы получаем оставшиеся позиционные аргументы
args := flag.Args()
if len(args) == 0 {
// Если не передали обязательный аргумент, показываем ошибку
fmt.Fprintln(os.Stderr, "ошибка - не указан входной файл")
os.Exit(2)
}
input := args[0]
if verbose {
// Здесь мы выводим отладочную информацию, если включен verbose
fmt.Fprintf(os.Stderr, "обработка файла %s, вывод в %s\n", input, output)
}
// Здесь могла бы быть основная логика обработки файла
result := "обработанные данные"
// Здесь мы записываем результат в файл
err := os.WriteFile(output, []byte(result), 0644)
if err != nil {
// Здесь мы выводим ошибку записи файла
fmt.Fprintf(os.Stderr, "ошибка записи файла - %v\n", err)
os.Exit(1)
}
// Здесь мы завершаем программу с кодом 0 - успех
}
Этот пример показывает базу:
- объявление флагов;
- разбор аргументов;
- обработку ошибок и коды возврата.
Но когда нужны подкоманды (app build, app deploy), стандартной flag становится мало. Тогда используют библиотеки вроде Cobra, urfave/cli и т.п.
CLI на Go с подкомандами: пример на Cobra
Cobra — популярный фреймворк для CLI в Go. Много известных инструментов (например, kubectl) сделано с его помощью.
Давайте разберемся на примере мини-утилиты mytool с командами hello и version.
Структура проекта:
cmd/root.go— корневая команда;cmd/hello.go— подкомандаhello;cmd/version.go— подкомандаversion;main.go— входная точка.
main.go:
package main
import "mytool/cmd"
func main() {
// Здесь мы запускаем корневую команду CLI
cmd.Execute()
}
cmd/root.go:
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// Здесь мы создаем корневую команду
var rootCmd = &cobra.Command{
Use: "mytool", // Имя команды, как её вызывают в терминале
Short: "Пример CLI на Cobra", // Краткое описание
Long: "Mytool - это пример..." , // Более подробное описание
}
// Execute запускает корневую команду
func Execute() {
// Здесь мы обрабатываем ошибку выполнения команды
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) // Выводим ошибку в stderr
os.Exit(1) // Выходим с кодом ошибки
}
}
func init() {
// Здесь можно добавить глобальные флаги, общие для всех подкоманд
// Например - mytool --verbose ...
}
cmd/hello.go:
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// Здесь мы объявляем переменную для флага
var name string
// Здесь мы создаем подкоманду hello
var helloCmd = &cobra.Command{
Use: "hello",
Short: "Печатает приветствие",
RunE: func(cmd *cobra.Command, args []string) error {
// Здесь мы реализуем основную логику команды
if name == "" {
// Если имя не указано - используем значение по умолчанию
name = "мир"
}
fmt.Printf("Привет, %s\n", name)
return nil
},
}
func init() {
// Здесь мы добавляем команду hello к корневой команде
rootCmd.AddCommand(helloCmd)
// Здесь мы добавляем флаг --name к команде hello
helloCmd.Flags().StringVarP(&name, "name", "n", "", "имя для приветствия")
}
cmd/version.go:
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// Здесь мы задаем версию - в реальном проекте её часто подставляют при сборке
var version = "0.1.0"
// Здесь мы создаем команду version
var versionCmd = &cobra.Command{
Use: "version",
Short: "Показывает версию программы",
RunE: func(cmd *cobra.Command, args []string) error {
// Здесь мы печатаем версию
fmt.Println(version)
return nil
},
}
func init() {
// Здесь мы регистрируем команду version
rootCmd.AddCommand(versionCmd)
}
Как видите, каждая команда реализуется как отдельный объект Cobra. Такой подход:
- помогает структурировать код;
- упрощает добавление новых команд;
- автоматически дает вложенный
--help.
Например:
mytool --help— общая справка;mytool hello --help— справка по конкретной команде.
CLI на Python: пример с argparse
Python часто используют для CLI-утилит благодаря простоте и богатой стандартной библиотеке. Базовый инструмент — модуль argparse.
Простой пример:
import argparse
import sys
def main():
# Здесь мы создаем парсер аргументов
parser = argparse.ArgumentParser(
description="Пример Python CLI инструмента"
)
# Здесь мы добавляем флаг --verbose
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="включить подробный вывод"
)
# Здесь мы добавляем позиционный аргумент input
parser.add_argument(
"input",
help="путь к входному файлу"
)
# Здесь мы разбираем аргументы командной строки
args = parser.parse_args()
if args.verbose:
# Здесь мы выводим дополнительную информацию, если включен verbose
print(f"Обработка файла {args.input}...", file=sys.stderr)
# Здесь могла бы быть логика обработки файла
print(f"Результат обработки {args.input}")
if __name__ == "__main__":
# Здесь мы вызываем основную функцию
main()
Аргумент --help создается автоматически. Если вы попробуете:
python tool.py --help
вы получите сгенерированную справку.
Подкоманды в argparse
Теперь давайте посмотрим, как сделать подкоманды, например calc add и calc mul.
import argparse
def cmd_add(args):
# Здесь мы реализуем команду сложения
result = args.a + args.b
print(result)
def cmd_mul(args):
# Здесь мы реализуем команду умножения
result = args.a * args.b
print(result)
def main():
# Здесь мы создаем корневой парсер
parser = argparse.ArgumentParser(
prog="calc",
description="Пример калькулятора с подкомандами"
)
# Здесь мы создаем подкоманды
subparsers = parser.add_subparsers(
title="команды",
dest="command"
)
# Здесь мы добавляем подкоманду add
parser_add = subparsers.add_parser("add", help="сложить два числа")
parser_add.add_argument("a", type=int, help="первое число")
parser_add.add_argument("b", type=int, help="второе число")
parser_add.set_defaults(func=cmd_add)
# Здесь мы добавляем подкоманду mul
parser_mul = subparsers.add_parser("mul", help="умножить два числа")
parser_mul.add_argument("a", type=int, help="первое число")
parser_mul.add_argument("b", type=int, help="второе число")
parser_mul.set_defaults(func=cmd_mul)
# Здесь мы разбираем аргументы
args = parser.parse_args()
if not hasattr(args, "func"):
# Если команда не указана - показываем справку
parser.print_help()
return
# Здесь мы вызываем соответствующую функцию для подкоманды
args.func(args)
if __name__ == "__main__":
main()
Здесь я размещаю пример, чтобы вам было проще понять структуру:
- есть корневой парсер;
- через
add_subparsersсоздаются подкоманды; - каждая подкоманда получает свою функцию-обработчик.
CLI на Node.js: пример с Commander
Для JavaScript / Node.js одна из популярных библиотек — Commander. Она позволяет довольно быстро собрать удобный CLI.
Пример простого инструмента:
#!/usr/bin/env node
// Здесь мы подключаем Commander
const { Command } = require("commander");
const fs = require("fs");
const program = new Command();
// Здесь мы задаем основную информацию о программе
program
.name("mycli")
.description("Пример CLI на Node.js")
.version("0.1.0");
// Здесь мы добавляем команду read
program
.command("read")
.description("прочитать файл и вывести содержимое")
.argument("<file>", "путь к файлу")
.option("-u, --uppercase", "вывести текст в верхнем регистре")
.action((file, options) => {
// Здесь мы читаем файл
fs.readFile(file, "utf8", (err, data) => {
if (err) {
// Здесь мы выводим ошибку, если файл не прочитан
console.error("Ошибка чтения файла", err.message);
process.exit(1);
}
// Здесь мы применяем опцию --uppercase
if (options.uppercase) {
data = data.toUpperCase();
}
// Здесь мы печатаем результат
console.log(data);
});
});
// Здесь мы разбираем аргументы и запускаем нужную команду
program.parse(process.argv);
Чтобы этот файл можно было запускать как команду, вы:
- Добавляете в начало шебанг
#!/usr/bin/env node. - Даете ему права на исполнение.
- Устанавливаете как глобальный пакет через npm или pnpm.
Например, через npm:
npm init -y
npm install commander
# здесь вы настраиваете в package.json секцию "bin"
npm link
Теперь команда mycli будет доступна в системе как глобальный CLI-инструмент.
Работа с конфигурацией
CLI-инструменты часто получают настройки не только через флаги, но и через:
- конфигурационные файлы;
- переменные окружения.
Такой подход удобен, когда:
- флагов становится слишком много;
- вам нужно переиспользовать одинаковые настройки.
Приоритеты конфигураций
Частая схема:
- Значения по умолчанию.
- Конфигурационный файл (например,
~/.toolrcилиtool.yamlв проекте). - Переменные окружения (
TOOL_TOKEN,TOOL_URL). - Параметры командной строки (флаги).
Ближе к пользователю — выше приоритет. Например, флаг --url должен переопределить значение из конфигурационного файла.
Пример на Python — читаем конфиг и переопределяем его флагами:
import argparse
import json
import os
def load_config(path):
# Здесь мы пытаемся прочитать конфигурационный файл
if not os.path.exists(path):
return {}
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def main():
# Здесь мы читаем конфиг по умолчанию
default_config_path = os.path.expanduser("~/.mytool.json")
config = load_config(default_config_path)
# Здесь мы создаем парсер
parser = argparse.ArgumentParser(description="Пример с конфигом")
# Здесь мы добавляем флаг --url с значением по умолчанию из конфига
parser.add_argument(
"--url",
default=config.get("url", "https://api.example.com"),
help="URL сервиса"
)
args = parser.parse_args()
# Здесь мы используем итоговый URL, который возможен из конфига или флага
print(f"Используем URL - {args.url}")
if __name__ == "__main__":
main()
Давайте посмотрим, что происходит в этом примере:
- если конфиг не найден — берем дефолтное значение;
- если конфиг есть — берем значение из него;
- если задан флаг
--url— он переопределит конфиг.
Логирование и уровни подробности
Многие CLI имеют флаг --verbose или -v, иногда несколько уровней: -v, -vv, -vvv.
Задача:
- не загромождать стандартный вывод обычному пользователю;
- давать достаточно информации для отладки, когда это нужно.
Пример на Go — несколько уровней подробности:
package main
import (
"flag"
"fmt"
"os"
)
var verbose int
func debug(level int, format string, args ...interface{}) {
// Здесь мы печатаем сообщение, только если текущий уровень verbose
// не меньше уровня сообщения
if verbose >= level {
msg := fmt.Sprintf(format, args...)
fmt.Fprintln(os.Stderr, msg)
}
}
func main() {
// Здесь мы добавляем флаг -v, который можно указать несколько раз
flag.CountVar(&verbose, "v", "уровень подробности вывода")
flag.Parse()
debug(1, "запуск программы")
debug(2, "подробная отладочная информация")
// Здесь основная логика CLI
fmt.Println("Основной результат")
}
В этом примере:
-v— уровень 1;-vv— уровень 2;-vvv— и так далее.
Вы можете выводить менее важные сообщения только при большем уровне.
Тестирование CLI-инструмента
Чтобы CLI уверенно работал, важно его тестировать. Обычно это:
- юнит-тесты основных функций;
- интеграционные тесты — запуск программы как бинарника и проверка вывода и кода выхода.
Интеграционный тест на Go
Здесь я покажу вам пример через os/exec:
package main_test
import (
"os/exec"
"testing"
)
func TestMyToolHelp(t *testing.T) {
// Здесь мы готовим команду для запуска
cmd := exec.Command("mytool", "--help")
// Здесь мы собираем вывод
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("ожидался код возврата 0, получили ошибку - %v", err)
}
// Здесь мы проверяем, что в выводе есть ключевая строка
if !contains(string(output), "Пример CLI") {
t.Fatalf("в выводе не найдено описание - %s", output)
}
}
func contains(str, substr string) bool {
// Здесь простая функция поиска подстроки
return len(str) >= len(substr) && (str == substr || len([]rune(str)) >= len([]rune(substr)) && (string([]rune(str)[:len([]rune(substr))]) == substr || contains(string([]rune(str)[1:]), substr)))
}
На практике вы будете использовать более аккуратные вспомогательные функции для проверки строки. Важно, что тест:
- запускает CLI как внешний процесс;
- проверяет код возврата;
- анализирует вывод.
Так вы защищаете инструмент от регрессий.
Распространение и установка CLI
После разработки нужно, чтобы ваш cli-tool было легко установить и использовать.
Один бинарник (Go, Rust и др.)
Для языков, которые собирают статический бинарник, удобно:
- выкладывать релизы на GitHub Releases;
- использовать менеджеры версий и установщики (например,
brew,scoop,apt,dnf,chocolatey).
Частый паттерн:
- вы создаете GitHub-репозиторий;
- добавляете CI, который собирает бинарники под нужные платформы;
- публикуете их как релизы;
- пишете в README инструкции:
- curl-скрипт загрузки;
- команды для установки через пакетные менеджеры.
Python: pip и консольные скрипты
В Python CLI обычно распространяют как пакет, у которого в setup.cfg или pyproject.toml указаны entry points.
Пример фрагмента pyproject.toml:
[project]
name = "mytool"
version = "0.1.0"
[project.scripts]
mytool = "mytool.__main__:main"
Здесь:
mytool— имя команды в консоли;mytool.__main__:main— путь к функции Python, которая запускается.
После установки через pip install . команда mytool появится в PATH.
Node.js: npm global
Мы уже коснулись этого, но уточню схему:
- в
package.jsonдобавляется секция:
{
"name": "mycli",
"version": "0.1.0",
"bin": {
"mycli": "./bin/mycli.js"
}
}
- ставим пакет глобально:
npm install -g .
Теперь mycli доступна как команда.
Удобство использования и UX CLI
CLI — это тоже интерфейс, только текстовый. Есть несколько практик, которые сильно влияют на удобство.
Справка и примеры
Старайтесь:
- описывать каждый флаг и команду;
- добавлять раздел с примерами использования.
Например, в выводе --help вы можете сделать:
Примеры:
mytool hello --name Иван
mytool version
Это помогает новичкам быстро запустить инструмент, не читая длинную документацию.
Понятные сообщения об ошибках
Сообщения вида "error 42" не дают пользователю почти ничего. Лучше:
- указать, что именно не так;
- подсказать, как исправить.
Например:
- Плохо:
Invalid argument - Лучше:
Неизвестная команда deploy. Проверьте список доступных команд через --help
Безопасность по умолчанию
Если ваш инструмент может:
- удалять файлы;
- менять конфигурацию;
- отправлять запросы в продакшн;
стоит:
- явно требовать подтверждения (или флаг
--force); - иметь режим
--dry-run(показывать, что будет сделано без применения).
Так вы уменьшите риск случайных ошибок.
Заключение
CLI-инструменты — ключевой элемент рабочего процесса разработчика. Они автоматизируют рутинные операции, связывают разные системы, упрощают деплой и тестирование. Вы посмотрели, как спроектировать понятный и удобный CLI:
- продумать команды и флаги;
- аккуратно работать с входом и выходом;
- обрабатывать ошибки и формировать коды возврата;
- использовать конфигурацию, окружение и логирование.
Мы прошлись по нескольким практическим примерам на bash, Go, Python и Node.js, чтобы вы могли выбрать подходящий инструмент под свой стек и задачу. Теперь у вас есть база, на которой можно строить как небольшие утилиты для своей команды, так и серьезные cli-tools, которыми будут пользоваться другие разработчики.
Частозадаваемые технические вопросы по теме CLI инструменты
Как сделать автодополнение команд и флагов для моего CLI
Для популярных фреймворков (например, Cobra в Go) есть встроенная генерация скриптов автодополнения для bash, zsh, fish. Инструкция:
- Посмотрите в документации библиотеки раздел про completion.
- Сгенерируйте скрипт, например
mytool completion bash > /etc/bash_completion.d/mytool. - Перезапустите терминал или загрузите файл
source /etc/bash_completion.d/mytool. Для Python и Node.js существуют отдельные пакеты, которые помогают генерировать подобные скрипты или интегрировать с существующими системами автодополнения.
Как безопасно передавать секреты в CLI инструменте
Рекомендуется не передавать пароли и токены через аргументы командной строки, так как они могут попасть в историю shell и быть видимыми через ps. Лучше:
- Использовать переменные окружения
MYTOOL_TOKEN. - Или читать секреты из защищенного файла (права 600) в домашней директории.
- Или запрашивать ввод через stdin с маскировкой (например,
getpassв Python).
Как сделать, чтобы мой CLI работал кроссплатформенно
Основные шаги:
- Не полагаться на специфичные команды ОС (
ls,grep) без проверки. - Избегать жестко прописанных путей вроде
/tmpилиC:\Temp— использовать функции стандартной библиотеки для временных каталогов и домашней директории. - Тестировать CLI минимум на Linux, macOS и Windows (включая PowerShell).
- Для бинарников на Go или Rust собирать их для разных платформ и архитектур через систему сборки или CI.
Как обрабатывать очень длинный вывод так, чтобы его было удобно читать
Несколько подходов:
- Добавить флаг
--formatи поддержать вариантыtext,json,yaml. - Для человека по умолчанию выводить человекочитаемый текст, для скриптов — JSON.
- Добавить флаг
--pager, который отправляет вывод вlessили аналог, если пользователь этого хочет. - При большом объеме данных добавлять заголовки, разделители и краткую сводку в конце.
Как версионировать CLI и не ломать старые скрипты пользователей
Основные практики:
- Явно указывать версию в выводе
--version. - Избегать изменения поведения без изменения мажорной версии.
- Для несовместимых изменений:
- повышать мажорную версию;
- документировать изменения в CHANGELOG;
- по возможности оставлять временные алиасы старого поведения с предупреждениями.
- Если требуется резкий переход, можно поддерживать несколько бинарников, например
toolиtool2, в течение переходного периода.