---
title: "Интерактивный ввод: вопросы и подтверждения"
description: "Интерактивный CLI на Go: bufio.Scanner для вопросов, подтверждения y/N, меню выбора, пароль без эха через term и флаг --yes для скриптов и CI. Примеры для Cobra."
course: "Cobra: CLI-приложения на Go"
url: "https://golangmaster.ru/courses/cobra/lessons/15-interaktivnyj-vvod/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Интерактивный ввод: вопросы и подтверждения

*Курс «Cobra: CLI-приложения на Go», урок 15 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cobra/lessons/15-interaktivnyj-vvod/*

Интерактивный CLI на Go строится на трёх примитивах: bufio.Scanner для вопросов с клавиатуры, подтверждение y/N перед разрушительными действиями и пароль без эха через пакет term. Разберём, когда вопросы уместны, как не зависнуть в CI и почему у каждой интерактивной команды обязан быть флаг --yes.

notes reset без вопросов — утилита, которую страшно запускать: одна команда стирает хранилище целиком, и случайная стрелка вверх в истории шелла превращается в потерянные заметки. Интерактивный ввод лечит это: перед разрушительным действием утилита задаёт вопрос и ждёт ответа. Но медаль двусторонняя: тот же вопрос, вставленный в скрипт или CI, либо зависнет навсегда, либо прочтёт мусор из пустого stdin. Поэтому тема урока — не «как спросить», а пара: как спросить у человека и как дать скрипту способ пройти мимо вопроса. Всё это живёт внутри команды Cobra естественным образом: ввод читается в RunE, вопрос печатается в тот же поток, что и остальной вывод, а флаг --yes открывает непрерывный путь для автоматизации. Соберём набор помощников, который потом переедет в итоговый проект курса.

### Интерактивный ввод в CLI: bufio.Scanner поверх stdin

Стандартный способ прочитать строку — bufio.Scanner поверх os.Stdin: scanner := bufio.NewScanner(os.Stdin), затем scanner.Scan() ждёт строку, а scanner.Text() отдаёт её без перевода строки. В контексте Cobra это функция-помощник, которую зовут из RunE: напечатать вопрос через cmd.OutOrStdout(), считать ответ, вернуть строку. Сканер создаётся один на поток ввода: повторные Scan по одному сканеру читают следующие строки, а два сканера на одном stdin начинают делить буфер и терять ввод — заведите один глобальный или передавайте его с параметрами. У сканера есть лимит длины строки — 64 КБ по умолчанию, для ответов на вопросы этого хватает с запасом. Основы Scan мы разбирали в уроке про [ввод с клавиатуры](/courses/go-basics/lessons/10-scan-vvod/) базового курса; здесь посмотрим только специфику CLI — что происходит, когда stdin не терминал, и как читать то, что печатать не принято.

### Помощник ask: вопрос и одна строка

```go
func ask(cmd *cobra.Command, prompt string) string {
        fmt.Fprintf(cmd.OutOrStdout(), "%s: ", prompt)
        scanner := bufio.NewScanner(os.Stdin)
        if !scanner.Scan() {
                return "" // EOF или ошибка ввода
        }
        return strings.TrimSpace(scanner.Text())
}
```

Помощник печатает подсказку в OutOrStdout — как и справка, вопрос уважает перенаправление вывода — и читает одну строку из stdin. Scan возвращает false на конце ввода: скрипт с закрытым stdin получит пустой ответ вместо зависания. TrimSpace спасает от случайных пробелов и перевода строк Windows-стиля.

### Подтверждение y/N: дефолт против действий

Подтверждение — не просто вопрос, а вопрос с безопасным дефолтом. Конвенция Unix читается по регистру букв в приглашении: [y/N] с заглавной N означает «пустой ввод — нет», пользователь нажал Enter случайно, и ничего не случилось. Обратный вариант [Y/n] с дефолтом «да» оставьте для действий, которые почти всегда нужны, — но разрушительные действия к ним не относятся никогда. Разбор ответа держите лояльным: Y, y и yes принимаются, всё остальное — отказ, без переспрашиваний и циклов до ввода корректного значения. Показывайте в вопросе масштаб: не «удалить заметки?», а «удалить 142 заметки из ~/notes? отменить это нельзя» — цена нажатия y должна быть видна до нажатия. И печатайте итог после ответа: «удалено 142 заметки» — подтверждение без отчёта выглядит как ложь, потому что пользователь не знает, что именно произошло после его y. Нюанс на продакшене: приглашение из ask уезжает в OutOrStdout, и при перенаправленном stdout оно окажется в файле вместо экрана — пользователь увидит висящую утилиту без вопроса. Если команда рассчитана на оба режима, вопрос печатайте в cmd.ErrOrStderr(): stderr в терминал приходит всегда.

### Помощник confirm и команда reset

```go
func confirm(cmd *cobra.Command, prompt string) bool {
        fmt.Fprintf(cmd.OutOrStdout(), "%s [y/N] ", prompt)
        scanner := bufio.NewScanner(os.Stdin)
        if !scanner.Scan() {
                return false // пустой stdin: отказ
        }
        answer := strings.ToLower(strings.TrimSpace(scanner.Text()))
        return answer == "y" || answer == "yes"
}

func runReset(cmd *cobra.Command, args []string) error {
        n, err := countNotes()
        if err != nil {
                return fmt.Errorf("count notes: %w", err)
        }
        if !confirm(cmd, fmt.Sprintf("Удалить %d заметок?", n)) {
                return errDeclined
        }
        return clearStorage()
}
```

confirm читает одну строку и принимает только y и yes в любом регистре; пустой ввод и всё остальное — отказ. В вопрос подставляется посчитанное число заметок, а не абстрактное «всё». Отказ возвращается как ошибка errDeclined: команда не выполнилась, и код выхода это покажет.

### Проверка: случайный Enter

**Вопрос:** Пользователь запустил notes reset, увидел «Удалить 142 заметок? [y/N]» и нажал Enter. Что произойдёт?

- Все заметки удалятся: пустой ввод — согласие
- ✅ Ничего: пустой ввод читается как отказ при дефолте N
- Вопрос повторится до ввода y
- Программа завершится с паникой на пустой строке

Заглавная N в [y/N] обещает: пустой ввод — отказ. confirm вернёт false, RunE завершится errDeclined с ненулевым кодом выхода. Это и есть безопасный дефолт: случайный Enter физически не может ничего удалить.

### Что вернёт confirm на « Y »

```go
// stdin содержит строку "  Y  "
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
answer := strings.ToLower(strings.TrimSpace(scanner.Text()))
fmt.Println(answer == "y")
```

**Что выведет программа?**

- ✅ true
- false
- y
- ошибка: Scanner не читает пробелы

TrimSpace срезает пробелы по краям, ToLower приводит к нижнему регистру — остаётся y, сравнение даёт true. Без TrimSpace ответ « Y » считался бы отказом: пользователи не должны гадать, откуда взялся лишний пробел в их вводе.

### Выбор из вариантов: нумерованное меню

Когда ответ — не да или нет, а один из вариантов, работает нумерованное меню: печатаете пронумерованный список, читаете число, проверяете диапазон. Формат устойчивее свободного ввода: пользователь не опечатается в названии, а вам не придётся угадывать синонимы. Дефолтный вариант нужен и здесь: пустой ввод выбирает первый пункт или самый безопасный. Для типов заметок, профилей, целей деплоя меню из трёх-семи пунктов читается лучше, чем флаг с перечислением значений в справке; больше семи — время пересматривать интерфейс команды, а не удлинять меню на экран. Разбор строки в число — strconv.Atoi с проверкой ошибки: буква вместо цифры не должна валить утилиту, это обычный отказ, равнозначный нажатию Ctrl+C на вопросе да или нет. В скриптах меню проходят флагом вместо ввода: добавьте к команде --kind и выбирайте вариант без вопросов, а choose оставьте для живого терминала — тот же принцип, что у --yes ниже.

### Помощник choose: меню с дефолтом

```go
func choose(cmd *cobra.Command, prompt string, options []string) int {
        fmt.Fprintln(cmd.OutOrStdout(), prompt)
        for i, opt := range options {
                fmt.Fprintf(cmd.OutOrStdout(), "  %d) %s
", i+1, opt)
        }
        fmt.Fprint(cmd.OutOrStdout(), "Номер [1]: ")
        line := ask(cmd, "")
        if line == "" {
                return 0 // дефолт — первый пункт
        }
        n, err := strconv.Atoi(line)
        if err != nil || n < 1 || n > len(options) {
                return -1 // отказ
        }
        return n - 1
}
```

Меню печатается с единицы: люди считают с единицы, а не с нуля. Пустой ввод выбирает дефолт, буква или число вне диапазона дают отказ вместо паники — Atoi возвращает ошибку, диапазон проверяется явно. Возврат -1 разберите в RunE как отказ пользователя и верните ошибку. Ноль и минус единица — разные значения: дефолт — это выбор, отказ — ошибка, и смешивать их нельзя.

### Пароль без эха: пакет term

Пароль, набранный в открытом терминале, видно соседям по переговорке, программе записи экрана и истории scrollback. Стандартная библиотека эхо не отключает — это делает пакет golang.org/x/term из официального модуля x: term.ReadPassword(int(os.Stdin.Fd())) читает строку с выключенным эхом и возвращает срез байт. Подсказку «Пароль:» печатайте до чтения — ReadPassword сам ничего не выводит. И после чтения поставьте fmt.Println() руками: ReadPassword перевод строки не добавляет, и следующий вывод приклеится к приглашению. Модуль x/term ставится отдельным go get и не тянет зависимостей — для CLI-утилиты это нормальная цена за базовую гигиену ввода. В CI-сценарии пароль всё равно придёт из переменной окружения, а не из клавиатуры, — о пути для скриптов поговорим ниже.

### ReadPassword: ввод без отображения

```go
import "golang.org/x/term"

func askPassword(cmd *cobra.Command) (string, error) {
        fmt.Fprint(cmd.OutOrStdout(), "Пароль: ")
        pass, err := term.ReadPassword(int(os.Stdin.Fd()))
        fmt.Println() // ReadPassword не печатает перевод строки
        if err != nil {
                return "", fmt.Errorf("read password: %w", err)
        }
        return string(pass), nil
}
```

ReadPassword получает дескриптор stdin через os.Stdin.Fd() и возвращает байты без эха: символы не появляются на экране. Подсказка печатается отдельно, перевод строки — руками после чтения. Ошибка возможна, когда stdin не терминал, — на этот случай в следующем блоке есть сценарий.

> **Вопрос, который повесил ночной билд.** Скрипт вызывает notes reset в CI, утилита печатает вопрос и ждёт ответа из stdin. В CI stdin пуст: Scanner мгновенно получает EOF, confirm честно отказывает, билд падает с загадочной ошибкой — или, хуже, с Y в логе, случайно напечатанным во время отладки. Правило железное: у каждой интерактивной команды есть непрерывный путь — флаг --yes или переменная окружения, а вопрос задаётся только когда stdin вообще терминал: term.IsTerminal(os.Stdin.Fd()). Проверку терминала делайте до первого вопроса, а не после того, как билд повис на три часа до таймаута раннера. Все помощники из этого урока читают живой stdin — проверить их можно только локально в терминале: песочница интерактивный ввод не эмулирует.

### Флаг --yes: путь для скриптов

Стандартное решение — флаг --yes: cmd.Flags().BoolP("yes", "y", false, "не спрашивать подтверждение"). В RunE порядок строгий: если --yes не передан и stdin не терминал — откажитесь сразу с подсказкой, как обойти вопрос; если --yes не передан, а терминал есть — задайте вопрос; если --yes передан — выполняйте без вопросов. Отдельная честность: --yes «на всё» — компромисс с риском, и для самого разрушительного уровня утилиты вводят второй барьер — отдельный флаг --force или обязательное длинное значение вроде --yes-i-want-to-delete-142-notes. Готовые библиотеки вроде huh или survey дают красивые виджеты выбора и маскированный ввод — взгляните, когда помошников станет мало, но три функции из этого урока покрывают 90% потребностей CLI без единой зависимости сверх x/term.

### Команда с --yes и проверкой терминала

```go
var resetCmd = &cobra.Command{
        Use: "reset",
        RunE: func(cmd *cobra.Command, args []string) error {
                yes, _ := cmd.Flags().GetBool("yes")
                if !yes && !term.IsTerminal(os.Stdin.Fd()) {
                        return fmt.Errorf("stdin не терминал: передайте --yes")
                }
                if !yes && !confirm(cmd, "Удалить все заметки?") {
                        return errDeclined
                }
                return clearStorage()
        },
}

func init() {
        resetCmd.Flags().BoolP("yes", "y", false, "не спрашивать подтверждение")
}
```

Три ветки: без --yes и без терминала — отказ с подсказкой; без --yes с терминалом — вопрос; с --yes — прямое выполнение. И отказ пользователя, и отказ автоматизации возвращают ошибку: код выхода отличит отмену от работы. IsTerminal проверяет stdin до первого вопроса — CI не зависнет.

### Проверка: запуск в CI

**Вопрос:** notes reset без --yes запущен в CI со связанным пустым stdin. Что сделает утилита из этого урока?

- Зависнет в ожидании ввода до таймаута
- ✅ Задаст вопрос, получит EOF и откажет через errDeclined
- Выполнит удаление: пустой stdin — это согласие
- Запаникует на чтении закрытого stdin

Базовый confirm на EOF возвращает false — отказ придёт быстро и с внятной ошибкой, а не зависанием. Но правильнее перехватить случай раньше: проверка IsTerminal в RunE даст сообщение с подсказкой про --yes ещё до вопроса.

### Соберите чтение строки и пароля

Впишите пакет сканера, переменную ошибки и метод дескриптора.

```go
scanner := ___.NewScanner(os.Stdin)

pass, ___ := term.ReadPassword(int(os.Stdin.___))
```

Подсказка: Сканер живёт в пакете bufio; ReadPassword возвращает срез байт и ошибку; дескриптор достаётся методом Fd().

Правильные ответы в пропусках: `bufio`, `err`, `Fd`

### Соберите runReset из шести строк

Расставьте: начало функции, чтение флага, вопрос при отсутствии --yes, отказ, закрытие if, само действие.

Правильный порядок строк:

1. `func runReset(cmd *cobra.Command, args []string) error {`
2. `  yes, _ := cmd.Flags().GetBool("yes")`
3. `  if !yes && !confirm(cmd, "Удалить заметки?") {`
4. `    return errDeclined`
5. `  }`
6. `  return clearStorage()`

### Карточки: интерактивный ввод

- **bufio.Scanner** — Читатель строк из stdin: Scan ждёт строку, Text отдаёт без перевода строки. Один сканер на поток ввода; EOF — Scan вернёт false.
- **[y/N]** — Подсказка с дефолтом: заглавная буква — что засчитывается при пустом вводе. y/N — пустой ввод означает нет; безопасно для разрушительных действий.
- **term.ReadPassword** — Чтение пароля без эха из golang.org/x/term: дескриптор через os.Stdin.Fd(), перевод строки после чтения ставится руками.
- **term.IsTerminal** — Проверка, что stdin — живой терминал. Делайте её до первого вопроса: в CI и скриптах ввода нет, путь — через флаг --yes.
- **--yes** — Флаг непрерывного пути для скриптов и CI: вопрос не задаётся, действие выполняется сразу. Обязателен для любой интерактивной команды.
- **errDeclined** — Ошибка отказа пользователя: подтверждение не получено, RunE возвращает её, и код выхода отличает отмену от успешного выполнения.

### Что дальше

Ввод — половина диалога с терминалом; вторая половина — вывод: в следующем уроке про [цвета и таблицы](/courses/cobra/lessons/16-cvetnoj-vyvod/) добавим к вопросам ANSI-цвета, fatih/color и правило NO_COLOR. Собирается всё вместе в [итоговом проекте](/courses/cobra/lessons/25-itogovyj-proekt/): там confirm стоит перед очисткой хранилища, а --yes открывает путь скриптам. Когда во время длинной операции пользователь жмёт Ctrl+C вместо ответа, процесс умирает вместе с вопросом — как завершаться корректно, разбирает урок про [сигналы и контексты](/courses/cobra/lessons/22-signaly-kontekst/); обзор курса — на странице [Cobra](/courses/cobra/). Домашняя работа на закрепление простая: добавьте подтверждение к самой разрушительной команде своей утилиты и не забудьте про флаг --yes — скрипты скажут спасибо.

## Коротко: что запомнить

- Ввод в Cobra читается в RunE: bufio.Scanner поверх os.Stdin, вопрос — в cmd.OutOrStdout().
- Подтверждение [y/N] при пустом вводе означает отказ: случайный Enter не запускает разрушительное действие.
- Меню выбора — нумерованный список с дефолтом и проверкой диапазона через strconv.Atoi.
- Пароль — term.ReadPassword с os.Stdin.Fd(): эхо выключено, перевод строки после чтения ставится руками.
- У любой интерактивной команды есть путь для скриптов: флаг --yes и проверка term.IsTerminal до первого вопроса.
- EOF в скрипте — не зависание: confirm на пустом stdin отказывает, команда возвращает ошибку.
- Показывайте в вопросе масштаб действия: 142 заметки, а не абстрактные «все данные».

## Домашнее задание

Добавьте в свою утилиту команду очистки с подтверждением: посчитайте записи, покажите их число в вопросе и откажитесь при пустом вводе. Затем повесьте флаг --yes и проверку term.IsTerminal: в терминале команда спрашивает, в скрипте с --yes — молчит, без --yes и без терминала — возвращает ошибку с подсказкой. Проверьте все три сценария локально и с перенаправленным stdin. Затем прогоните сценарий скрипта: echo y | ./notes reset без --yes должен отказать по IsTerminal, а с --yes — пройти молча.

## Вопросы и ответы по уроку

### Как прочитать ввод пользователя в команде Cobra?

bufio.NewScanner(os.Stdin) и scanner.Scan() внутри RunE: Scan ждёт строку, Text() возвращает её без перевода строки. Пустой stdin даёт false от Scan — обрабатывайте EOF как отказ, а не как согласие, и заведите один сканер на весь ввод.

### Почему команда с вопросом зависает в CI?

Утилита ждёт строку из stdin, а в CI он пуст или закрыт. Оберните вопрос проверкой term.IsTerminal(os.Stdin.Fd()) и добавьте флаг --yes: терминал получает вопрос, скрипты проходят без ввода, а без флага утилита отказывается сразу с подсказкой.

### Как сделать ввод пароля без отображения символов?

Пакет golang.org/x/term: term.ReadPassword(int(os.Stdin.Fd())) читает строку с выключенным эхом. Напечатайте подсказку до вызова и fmt.Println() после — ReadPassword сам перевод строки не добавляет, и следующий вывод прилипнет к приглашению.
