golang

Верстаем своего ИИ CLI агента на… GOLANG???

  • понедельник, 7 июля 2025 г. в 00:00:07
https://habr.com/ru/articles/925318/

Все уже слышали про Gemini CLI, который позволяет взаимодействовать с мощной gemini 2.5 прямо из командной строки. Это удобно, открывает массу возможностей. Но что, если мы захотим не просто использовать готовое решение, а понять, как оно работает изнутри? А если у нас вообще нету VPN для сервисов гугла? Или, что еще интереснее, создать свой собственный, пусть и экспериментальный, аналог? Чем мы хуже? Давайте сверстаем свой вариант на... GOLANG?

Да, именно так. Мы не будем писать продакшн-готовый инструмент, который заменит собой все существующие CLI. Наша цель — эксперимент, погружение в процесс, понимание того, как можно подружить Go, консоль и большую языковую модель.

Моя идея проста: создать относительно не сложного CLI-агента, который будет слушать наши запросы, обращаться к AI за советом или командой, предлагать эту команду нам для выполнения, а затем, после нашего подтверждения, выполнять её и анализировать результат. Если команда завершится ошибкой, AI попытается понять, что пошло не так, и предложит решение. Это позволит нам не только получать ответы, но и автоматизировать рутинные задачи, не выходя из терминала.

Если вам интересен процесс и вы хотите следить за дальнейшими материалами, буду признателен за подписку на мой телеграм-канал. Там я публикую полезныe материалы по разработке, разборы сложных концепций, советы как быть продуктивным и конечно же отборные мемы: https://t.me/nullPointerDotEXE.

В качестве зависимостей будем использовать github.com/fatih/color для красивого вывода. Поэтому сразу сделайте go get github.com/fatih/color

Шаг 1. Объявляем переменные, структуры, константы и инициализируемся

var (
	successColor = color.New(color.FgGreen).Add(color.Bold).SprintFunc()
	borderColor  = color.New(color.FgWhite).Add(color.Bold).SprintFunc()
	labelColor   = color.New(color.FgHiWhite).Add(color.Bold).SprintFunc()
	aiColor      = color.New(color.FgHiCyan).Add(color.Bold).SprintFunc()
	errorColor   = color.New(color.FgRed).Add(color.Bold).SprintFunc()
)

Начнем с переменных. Это не более, чем просто функции для подсветки текста. Мы же пишем аналог gemini, ведь так? Ну вот и текст будем делать тоже красивым.

Далее у нас идут инструкции для нейросети. Модельки я использую бесплатные, но не самые слабые. Конкретно тут я использую qwen3 на 30b параметров. Api ключ я покажу как позже получить на openrouter.

const (
	systemPromptBase            = "Вот системный контекст:\n"
	simpleChatPromptTemplate    = "Ты — AI-агент. Ответь на вопрос пользователя, основываясь на информации которая есть. НИКОГДА НЕ ИСПОЛЬЗУЙ СМАЙЛИКИ. вот история сообщений: %v"
	commandGenPromptTemplate    = "Ты — AI-агент. Твоя задача — генерировать команды для PowerShell на основе запроса пользователя.Отвечай только командой, без лишних слов, объяснений и markdown-форматирования. Только голая команда. Никогда не используй смайлики. Старайся генерить команды, которые не выводят очень длинный лог. И вот наши прошлые сообщения: %v"
	errorAnalysisPromptTemplate = "Проанализируй ошибку выполнения команды PowerShell и объясни простыми словами, что пошло не так. Исходный запрос: '%s'. Команда: '%s'. Ошибка: '%s'"
	summaryPromptTemplate       = "Кратко объясни результат выполнения команды, основываясь на первоначальном запросе пользователя. Исходный запрос: '%s'. Вывод команды: '%s'"
)

Ну тут сами прочитаете тексты. 1 промпт нужен для предоставлении базовой информации о пк. Это можно убрать, но если вы планируете делать такого агента под mac os или linux, то предоставление базовой информации нейросетке для генерации правильных команд необходимо. 2 промпт для режима чата. 3 для агента. 4 для анализа ошибок и 5 для отчетов.

Идем далее

type Message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type App struct {
	APIKey     string
	APIUrl     string
	Model      string
	MaxRetries int
	Client     *http.Client
	Reader     *bufio.Reader
	History    []Message
}

type APIRequest struct {
	Model    string    `json:"model"`
	Messages []Message `json:"messages"`
}

type APIResponse struct {
	Choices []Choice `json:"choices"`
}

type Choice struct {
	Message ResponseMessage `json:"message"`
}

type ResponseMessage struct {
	Content string `json:"content"`
}

Структурки.

первая структура для сохранения истории. Все остальные для взаимодействия с API нейросети. А вот app для инициализации приложения. Городить DI, конфигурацию и прочий оверхед в маленьком проекте я не буду. Сейчас у нас максимально лайтовая CLI'ка на каждый день.

Структурку App необходимо проинициализировать.

func NewApp() *App {

	return &App{
		APIKey:     "апи ключ",
		APIUrl:     "https://openrouter.ai/api/v1/chat/completions",
		Model:      "qwen/qwen3-30b-a3b:free",
		MaxRetries: 5,
		Client: &http.Client{
			Timeout: 60 * time.Second, //генерация может быть не самой быстрой
		},
		Reader: bufio.NewReader(os.Stdin),
		History: []Message{
			{
				Role:    "system",
				Content: systemPromptBase + getSystemInfo(),
			},
		},
	}
}

Но запомните: НИКОГДА НЕ ХАРДКОРДИТЕ АПИ КЛЮЧИ! ВОТ ВООБЩЕ НЕ НАДО ТАК ДЕЛАТЬ!! В данном случае мы пишем CLI чисто для эксперемента, а не в продакшн. Поэтому загружать конфиг с env я не вижу смысла тут. Но если будете расширять, то ОБЯЗАТЕЛЬНО ВЫНОСИТЕ КОНФИГУРАЦИЮ И САМОЕ ГЛАВНОЕ .ENV.

Функция getSystemInfo() возвращает базовую информацию о пк юзера.

func getSystemInfo() string {
	osName := runtime.GOOS
	currentUser, err := user.Current()
	username := "unknown"
	if err == nil {
		username = currentUser.Username
	}
	cwd, err := os.Getwd()
	if err != nil {
		cwd = "unknown"
	}
	return fmt.Sprintf(
		"System Context:\n- OS: %s\n- Shell: PowerShell\n- User: %s\n- CWD: %s\n",
		osName, username, cwd,
	)
}

Итак, давайте получим API ключ. Если у вас локальная модель или другая, то можете пропускать смело шаг 2.

Шаг 2. Получаем API ключ для нейросети на openrouter

Перейдите на сайт опенроутера и войдите любым удобным способ

После этого наведите курсор на свою иконку. Там будет вкладка "keys". Вот это вот вам туда вот.

Далее просто создаете ключ, копируете и вставляете.

Сложно? Не думаю.

Шаг 3. Создаем функцию генерации контента.

Cердце нашей утилиты, без которого оно не сможет функционировать хоть как-то. Функция максимально типичная и простая. Она делает запрос к API и возвращает ответ. Это ядро логики взаимодействия с внешним API генерации текста.

func (a *App) GenerateContent(messages []Message) (string, error) {
	// Формируем тело запроса с моделью и сообщениями
	reqBody := APIRequest{
		Model:    a.Model,
		Messages: messages,
	}

	// Сериализуем тело запроса в JSON
	bodyBytes, err := json.Marshal(reqBody)
	if err != nil {
		return "", fmt.Errorf("ошибка кодирования JSON: %w", err)
	}

	// Создаём HTTP POST-запрос к API
	req, err := http.NewRequest("POST", a.APIUrl, bytes.NewBuffer(bodyBytes))
	if err != nil {
		return "", fmt.Errorf("ошибка создания запроса: %w", err)
	}

	// Устанавливаем заголовки: тип контента и авторизация
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+a.APIKey)

	// Отправляем запрос и получаем ответ
	resp, err := a.Client.Do(req)
	if err != nil {
		return "", fmt.Errorf("ошибка выполнения запроса: %w", err)
	}
	defer resp.Body.Close()

	// Читаем тело ответа
	respBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("ошибка чтения ответа: %w", err)
	}

	// Проверяем успешность запроса по статус-коду
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("API вернул ошибку (статус %d): %s", resp.StatusCode, string(respBytes))
	}

	// Десериализуем JSON-ответ в структуру
	var result APIResponse
	if err := json.Unmarshal(respBytes, &result); err != nil {
		return "", fmt.Errorf("ошибка декодирования ответа: %w", err)
	}

	// Возвращаем контент первого ответа, если он есть
	if len(result.Choices) > 0 && result.Choices[0].Message.Content != "" {
		return result.Choices[0].Message.Content, nil
	}

	// Если контент не получен — возвращаем ошибку
	return "", fmt.Errorf("API не вернул контент в ответе")
}

Шаг 4. Выполняем команды от нейросети.

Напишем функцию, которая будет преобразовывать слова в действие.

func executeCommand(cmdStr string) (string, error) {
	cmd := exec.Command("powershell", "-NoProfile", "-Command", cmdStr)
	output, err := cmd.CombinedOutput()
	// Возвращаем вывод в любом случае, т.к. там может быть текст ошибки
	return strings.ToValidUTF8(string(output), ""), err
}

Функция executeCommand выполняет указанную строку команды через PowerShell, собирает весь вывод (включая ошибки), преобразует его в корректную UTF-8 строку и возвращает вместе с ошибкой (если она возникла). Даже если команда завершилась с ошибкой, текст её вывода всё равно возвращается — это важно для отображения сообщений об ошибках пользователю. Если в выводе будет кириллица, то символы будут битые. Поэтому мы переводим все в UTF8.

В дополнение к этой команде идет функция для очистки. Зачем? Я отвечу зачем. Хоть мы в промпте явно указываем, что генерировать команду надо сразу, но от фундаментальных проблем нейросети мы не лишены. Нейросеть банально может забыть инструкцию из-за длины контекста. Поэтому мы перестраховываемся.

func cleanCommand(cmdStr string) string {
	cmdStr = strings.TrimPrefix(cmdStr, "```powershell")
	cmdStr = strings.TrimPrefix(cmdStr, "```bash")
	cmdStr = strings.TrimPrefix(cmdStr, "```")
	cmdStr = strings.TrimSuffix(cmdStr, "```")
	cmdStr = strings.TrimPrefix(cmdStr, "Команда: ")
	cmdStr = strings.TrimPrefix(cmdStr, "Command: ")

	// Более надежное извлечение команды из `powershell -command "..."`
	if strings.HasPrefix(strings.ToLower(cmdStr), "powershell -command ") {
		firstQuote := strings.Index(cmdStr, "\"")
		lastQuote := strings.LastIndex(cmdStr, "\"")
		if firstQuote != -1 && lastQuote > firstQuote {
			cmdStr = cmdStr[firstQuote+1 : lastQuote]
		}
	}
	return strings.TrimSpace(cmdStr)
}

Функция cleanCommand очищает строку команды от лишнего форматирования и обёрток, которые могли быть добавлены нейросетью. Она удаляет префиксы вроде powershell`, bash, "Команда: ", "Command: ", а также обрезает завершающие ````.

Если строка начинается с конструкции powershell -command "...", то функция аккуратно извлекает содержимое внутри кавычек. В итоге возвращается чистая команда, готовая к выполнению.

Шаг 5. Пишем вспомогательные функции для красивого вывода

Тут я прям подробно заострять внимание не буду. В целом можно обойтись и без этих функций, но тк мы делаем свою gemini CLI, которая работает в России, то без них не обойтись.

Начнем с функции вывода огромной надписи CLI AGENT.

func printHeader() {
	var asciiHeader = []string{
		"  ███████╗██╗     ██╗     █████╗  ██████╗  █████╗ ███████╗███╗   ██╗████████╗",
		"  ██╔════╝██║     ██║    ██╔══██╗██╔════╝ ██╔══██╗██╔════╝████╗  ██║╚══██╔══╝",
		"  ██║     ██║     ██║    ███████║██║  ███╗███████║█████╗  ██╔██╗ ██║   ██║   ",
		"  ██║     ██║     ██║    ██╔══██║██║   ██║██╔══██║██╔══╝  ██║╚██╗██║   ██║   ",
		"  ███████╗███████╗██║    ██║  ██║╚██████╔╝██║  ██║███████╗██║ ╚████║   ██║   ",
		"  ╚══════╝╚══════╝╚═╝    ╚═╝  ╚═╝ ╚═════╝ ╚═╝  ╚═╝╚══════╝╚═╝  ╚═══╝   ╚═╝   ",
		"                                                                            ",
		"                             (by oyminirole)                                ",
	}
	colors := []*color.Color{
		color.New(color.FgHiCyan),
		color.New(color.FgCyan),
		color.New(color.FgHiBlue),
		color.New(color.FgBlue),
		color.New(color.FgHiMagenta),
		color.New(color.FgMagenta),
	}

	for _, line := range asciiHeader {
		lineLength := len(line)
		step := float64(len(colors)) / float64(lineLength)

		for i, char := range line {
			colorIndex := int(float64(i) * step)
			if colorIndex >= len(colors) {
				colorIndex = len(colors) - 1
			}
			colors[colorIndex].Printf("%c", char)
		}
		fmt.Println()
	}

	color.New(color.FgHiCyan).Add(color.Bold).Println("\nИнформация:")
	fmt.Println(" • Используйте перед запросом \"!\" для получения ответов на вопросы, или просто введите запрос. Пример: !Как создать папку?")
	fmt.Println(" • Для режима агента просто введите запрос без !. Агент будет исполнять команды и предоставлять результаты с отчетами.")
	fmt.Println(" • Для выхода из программы нажмите Ctrl+C или закройте окно терминала.")
	fmt.Println(strings.Repeat("─", 70))
}

Функция printHeader выводит в терминал красивый ASCII-баннер с градиентной цветовой заливкой, а затем — краткую справку по использованию CLI-интерфейса.

Сначала она построчно печатает логотип с псевдонимом (oyminirole это я кста), используя плавный градиент из шести цветов. Затем выводит инструкции для пользователя: как задавать вопросы (!), как использовать режим агента и как выйти из программы. Всё оформлено в стиле интерактивной CLI-помощи.

Куда же без спинера.

func startSpinner(text string) chan bool {
	stop := make(chan bool)
	go func() {
		frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇"}
		i := 0
		for {
			select {
			case <-stop:
				fmt.Print("\r\033[K") //выполняет очистку текущей строки в терминале
				return
			default:
				fmt.Print("\r\033[K") //выполняет очистку текущей строки в терминале
				fmt.Print(text + " ")
				color.New(color.FgHiCyan).Add(color.Bold).Printf(frames[i%len(frames)])
				time.Sleep(100 * time.Millisecond)
				i++
			}
		}
	}()
	return stop
}

Функция startSpinner запускает спиннер (анимацию загрузки) в отдельной горутине, чтобы визуально показать, что идёт фоновый процесс (например, генерация ответа).

Спиннер крутится в консоли, отображая символы из массива frames (символы типа , и т.д.). Каждые 100 мс обновляется кадр, пока в канал stop не придёт сигнал — тогда спиннер останавливается и строка очищается.

Возвращаемый канал chan bool используется, чтобы остановить анимацию извне.

Теперь напишем функции для отображения красивых ответов

func printResultBox(commandOutput, aiSummary string, makeAnalize bool) {
	width := 80

	fmt.Printf(" %s\n", successColor("╭─[ Результат ]"+strings.Repeat("─", width-16)))
	fmt.Printf(" %s %s\n", borderColor("│"), labelColor("Вывод команды:"))
	for _, line := range strings.Split(strings.TrimSpace(commandOutput), "\n") {
		fmt.Printf(" %s   %s\n", borderColor("│"), line)
	}
	if makeAnalize && aiSummary != "" {
		fmt.Printf(" %s %s\n", borderColor("│"), borderColor(strings.Repeat("·", width-4)))
		fmt.Printf(" %s %s\n", borderColor("│"), aiColor("Анализ AI:"))
		for _, line := range strings.Split(strings.TrimSpace(aiSummary), "\n") {
			fmt.Printf(" %s   %s\n", borderColor("│"), aiColor(line))
		}
	}
	fmt.Printf(" %s\n", successColor("╰"+strings.Repeat("─", width-2)))
}

func printErrorBox(errorOutput, aiAnalysis string) {
	width := 80

	fmt.Printf(" %s\n", errorColor("╭─[ ✗ Ошибка ]"+strings.Repeat("─", width-16)))
	fmt.Printf(" %s %s\n", borderColor("│"), labelColor("Лог ошибки:"))
	for _, line := range strings.Split(strings.TrimSpace(errorOutput), "\n") {
		fmt.Printf(" %s   %s\n", borderColor("│"), errorColor(line))
	}
	if aiAnalysis != "" {
		fmt.Printf(" %s %s\n", borderColor("│"), borderColor(strings.Repeat("·", width-4)))
		fmt.Printf(" %s %s\n", borderColor("│"), aiColor("Анализ AI:"))
		for _, line := range strings.Split(strings.TrimSpace(aiAnalysis), "\n") {
			fmt.Printf(" %s   %s\n", borderColor("│"), aiColor(line))
		}
	}
	fmt.Printf(" %s\n", errorColor("╰"+strings.Repeat("─", width-2)))
}

Знаю, что выглядит очень страшно, но ничего страшного они не делают. Запоминать вам это не надо. Эти функции просто выводят красиво текст в консоль.

Шаг 6. Пишем основную логику

Начнем с наименее страшной функции.

// handleSimpleChat обрабатывает запросы в режиме простого чата.
func (a *App) handleSimpleChat(userInput string) {
	spinner := startSpinner("Генерация...") 

	prompt := []Message{
		{Role: "system", Content: fmt.Sprintf(simpleChatPromptTemplate, a.History)},
		{Role: "user", Content: strings.TrimPrefix(userInput, "!")},
	}

	response, err := a.GenerateContent(prompt)
	if err != nil {
		printErrorBox(fmt.Sprintf("Ошибка генерации ответа:\n%v", err), "")
		return
	}
	spinner <- true
	printResultBox(response, "", false) 
}

Функция handleSimpleChat обрабатывает пользовательский ввод в режиме простого чата (вопрос-ответ) и выводит результат.

Что делает по шагам:

  1. Запускает спиннер с подписью "Генерация...", чтобы показать, что идёт обработка.

  2. Формирует промпт для нейросети:

    • системное сообщение с шаблоном simpleChatPromptTemplate, в который подставляется история общения (a.History);

    • пользовательское сообщение (ввод без ! в начале).

  3. Вызывает GenerateContent для получения ответа от AI.

  4. Обрабатывает ошибку, если она возникла — выводит её в виде красной рамки.

  5. Останавливает спиннер, если всё прошло успешно.

  6. Печатает результат в красивой рамке (printResultBox).

Итог: простой чат-режим, в котором мы получаем ответ на свой вопрос с анимацией ожидания и красивым выводом. Ничего сложного.

Идем дальше. Вот теперь мы подходим к самой логике агента. Но для начала напишем простенькую функцию для подтверждения команд.

func (a *App) askForConfirmation(command string) bool {
	fmt.Print("Подтвердить команду? [y/n]: ", aiColor(command), "\n> ") 
	confirmInput, _ := a.Reader.ReadString('\n')
	confirmInput = strings.ToLower(strings.TrimSpace(confirmInput))
	fmt.Print("\033[2A\033[J")
	return confirmInput == "y" || confirmInput == "yes"
}

Функция askForConfirmation запрашивает у пользователя подтверждение на выполнение команды и возвращает true, если ответ — положительный. Так же она очищает 2 строки вверх (\033[2A) и удаляет их содержимое (\033[J), чтобы убрать следы подтверждения из терминала.

Теперь сам агент.

func (a *App) handleCommandMode(userInput string) {
	currentTurnHistory := make([]Message, len(a.History))
	copy(currentTurnHistory, a.History)
	currentTurnHistory = append(currentTurnHistory, Message{Role: "user", Content: userInput})

	var lastError string
	for i := 0; i < a.MaxRetries; i++ {

		spinnerGen := startSpinner("Генерация команды...")
		prompt := []Message{
			{Role: "system", Content: fmt.Sprintf(commandGenPromptTemplate, currentTurnHistory)},
			{Role: "user", Content: userInput},
		}
		command, err := a.GenerateContent(prompt)
		spinnerGen <- true

		if err != nil {
			printErrorBox(fmt.Sprintf("Ошибка генерации команды:\n%v", err), "")
			return
		}
		command = cleanCommand(command)

		// 2. Подтверждение от пользователя
		if !a.askForConfirmation(command) {
			log.Println("Команда отменена пользователем.")
			return
		}
		currentTurnHistory = append(currentTurnHistory, Message{Role: "assistant", Content: command})

		// 3. Выполнение команды
		spinnerExec := startSpinner("Выполнение...") 
		output, err := executeCommand(command)      
		spinnerExec <- true

		if err != nil {
		
			currentTurnHistory = append(currentTurnHistory, Message{Role: "console error", Content: output})
			lastError = output
			time.Sleep(time.Second)
			continue // Переходим к следующей попытке
		}

		// 4. Успешное выполнение и подведение итогов
		spinnerSummary := startSpinner("Готовлю отчет...")
		summaryPrompt := []Message{{Role: "user", Content: fmt.Sprintf(summaryPromptTemplate, userInput, output)}}
		aiSummary, err := a.GenerateContent(summaryPrompt)
		if err != nil {
			log.Println("Ошибка генерации отчета: ", errorColor(err))
		}
		spinnerSummary <- true

		printResultBox(output, aiSummary, true)
		currentTurnHistory = append(currentTurnHistory, Message{Role: "AI", Content: aiSummary})

		a.History = currentTurnHistory // Сохраняем успешный диалог в основную историю
		return                         // Успешно завершили, выходим из функции
	}

	// 5. Обработка, если все попытки провалились
	spinnerAnalysis := startSpinner("Анализирую ошибки...") 
	analysisPrompt := []Message{{Role: "user", Content: fmt.Sprintf(errorAnalysisPromptTemplate, userInput, "N/A", lastError)}}
	aiAnalysis, _ := a.GenerateContent(analysisPrompt)
	spinnerAnalysis <- true

	printErrorBox(fmt.Sprintf("Не удалось выполнить задачу после %d попыток.", a.MaxRetries), aiAnalysis)
}

Согласен, пугает. Метод handleCommandMode реализует режим агента, в котором нейросеть генерирует команду по пользовательскому запросу, предлагает её на подтверждение, выполняет, а затем формирует краткий отчёт или анализирует ошибки при неудаче.

Пошаговая логика

  1. Создаётся копия истории сообщений текущего чата, чтобы работать в рамках одной сессии, не влияя на глобальную историю. Это сделано чтобы не забивать контекст.

  2. Запуск до MaxRetries попыток:

    • Генерация команды: нейросети передаётся промпт на основе истории и текущего ввода. Показывается спиннер.

    • Подтверждение: у пользователя спрашивается, можно ли выполнять команду. При отказе — функция завершает работу.

    • Выполнение команды: запускается в PowerShell, с анимацией выполнения. Если произошла ошибка — результат сохраняется, делается пауза и запускается следующая попытка.

  3. Если команда выполнена успешно:

    • Генерируется краткий AI-отчёт по результатам выполнения.

    • Вывод команды и отчёт красиво показываются через printResultBox.

    • Вся история этого раунда сохраняется в общую историю приложения.

  4. Если все попытки провалились:

    • Генерируется анализ ошибки (AI анализирует, почему не удалось).

    • Показывается красная рамка с числом неудачных попыток и анализом.

Назначение в CLI

Этот метод — ключевой элемент CLI-агента. Он превращает текстовый ввод пользователя в конкретную команду, контролирует выполнение, перехватывает ошибки и формирует осмысленные отчёты, обеспечивая высокий уровень автономности и интерактивности.

Шаг 7. Собираем все в единое целое.

Все самое страшное теперь позади. Давайте соберем наше приложение.

func isCommandPrefixed(input string) bool {
	return strings.HasPrefix(strings.ToLower(strings.TrimSpace(input)), "!")
}

func (a *App) Run() {
	printHeader() //да, это наш красивый заголовок

	for {
		fmt.Println("╭─" + strings.Repeat("─", 66))
		color.New(color.FgHiWhite).Print("│ > Введите запрос: ")
		userInput, _ := a.Reader.ReadString('\n')
		color.New(color.FgHiWhite).Println("╰" + strings.Repeat("─", 65))
		userInput = strings.TrimSpace(userInput)

		if userInput == "" {
			continue
		}

		if isCommandPrefixed(userInput) {
			a.handleSimpleChat(userInput)
		} else {
			a.handleCommandMode(userInput)
		}
	}
}

func main() {
	app := NewApp()
	defer app.Client.CloseIdleConnections() // Закрываем соединения при завершении
	app.Run()
}

Обработка пользовательского ввода

Каждый цикл начинается с вывода декоративной рамки и приглашения ко вводу ( > Введите запрос:). Введённая строка очищается от пробелов и анализируется:

  • Если строка начинается с восклицательного знака ! — система воспринимает её как вопрос или сообщение и передаёт на обработку в handleSimpleChat. Это режим диалога с нейросетью без выполнения команд.

  • Если строка не начинается с ! — считается, что пользователь хочет выполнить команду, и запуск происходит через handleCommandMode. В этом режиме нейросеть сначала генерирует команду, затем спрашивает подтверждение, выполняет её и формирует краткий отчёт.

Такой подход позволяет чётко разделять безопасные текстовые запросы и потенциально опасные системные действия.

Главный цикл приложения

Функция Run запускает бесконечный цикл обработки пользовательского ввода, обеспечивая непрерывную работу CLI-интерфейса. Перед этим выводится ASCII-заголовок. Каждое сообщение анализируется и передаётся в соответствующий режим, в зависимости от его структуры.

Запуск и завершение

Точка входа программы — функция main — создаёт экземпляр App с преднастроенными параметрами (HTTP-клиент, история, ввод и т.д.) и запускает основной цикл. При завершении работы соединения HTTP-клиента закрываются корректно через defer, что особенно важно при частом использовании API.

Результат

Итогом является интуитивно понятный CLI-интерфейс, где:

  • !Как создать папку? → возвращает ответ ИИ;

  • создай мне папку на рабочем столе → превращается в команду, исполняется, а затем поясняется.

Шаг 7. Восхищаемся результатом.

Поздравляю, мы получили максимально простую и красивую CLI'ку, которую спокойно можно переписывать как душе удобно, добавляя новый функционал. Причем с неплохими фишками. У нас нейросеть проводит саморефлексию и чинит ошибки. Что может быть лучше?

Ну и еще примерчик:

Команды можно выполнять и более сложные. Спокойно может поднять докер контейнеры, создать файлы и написать в них код, проанализировать тонну информации о вашем пк, найти всякие операции, которые жрут память и тд.

На мой взгляд приложение вышло крайне полезным и интересным.

Сори, что не выложил полный код на гитхаб. Позже добавлю, все распишу, так как ну вообще нету времени в данный момент у меня. Но поделиться интересным тоже хочется. На гитхаб уже опубликую более полноценную версию с env и конфигами, а так же чуть допилю функционал. Но в целом все основные моменты я вам показал.

По традиции жду ваших комментариев. Гудлак!