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

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

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

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

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

Код проекта доступен по ссылке. Буду признателен за ваш лайк.

Шаг 0. Структура проекта.

Для удобства мы разобьем код на несколько пакетов. Создайте следующую структуру папок и файлов:

agent/ 
├── cmd/
│   └── cli_agent/
│       └── main.go      # Точка входа в наше приложение
├── internal/
│   ├── app/
│   │   └── app.go       # Основная логика приложения
│   ├── config/
│   │   └── config.go    # Загрузка конфигурации
│   ├── shell/
│   │   └── shell.go     # Выполнение команд
│   └── ui/
│       └── ui.go        # Функции для интерфейса
├── .env
├── go.mod
└── go.sum

Cильно усложнять я не буду архитектуру, тк мы пишем простую утилиту на 500 строчек кода, а не продакшн оркестратор микросервисов. Поэтому ограничимся таким вариантом.

Шаг 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            = "System Context:\n"
	simpleChatPromptTemplate    = "You are an AI agent. Answer the user's question based on the information available. NEVER USE SMILEYS."
	commandGenPromptTemplate    = "You are an AI agent. Your task is to generate commands based on the user's query. Answer only with the command, without extra words, explanations, and markdown formatting. Only raw command. Never use smileys. Try to generate commands that do not produce very long logs"
	errorAnalysisPromptTemplate = "Analyze the error execution of command and explain simply what went wrong. Original query: '%s'. Error: '%s'"
	summaryPromptTemplate       = "Briefly explain the result of executing the command, based on the original user query. Original query: '%s'. Command output: '%s'"
)

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

Идем далее

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

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"`
}

type App struct {
	client       *http.Client
	history      []Message
	autoComplete bool
	reader       *bufio.Reader
	config       *config.Config
}

Структурки и конфиг.

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

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

func NewApp(config *config.Config, client *http.Client) *App {
	return &App{
		client:       client,
		autoComplete: false,
		reader:       bufio.NewReader(os.Stdin),
		config:       config,
		history: []Message{
			{
				Role:    "system",
				Content: systemPromptBase + "\n" + shell.GetSystemInfo(),
			},
		},
	}
}

И так же необходимо создать конфиг:

type Config struct {
	Model    string
	ApiUrl   string
	ApiToken string
	Timeout  int
	Retries  int
}

func LoadConfig() (*Config, error) {
	err := godotenv.Load()
	if err != nil {
		return nil, err
	}

	retries, err := strconv.Atoi(os.Getenv("RETRIES"))
	if err != nil {
		return nil, err
	}

	timeout, err := strconv.Atoi(os.Getenv("TIMEOUT"))
	if err != nil {
		return nil, err
	}

	return &Config{
		Model:    os.Getenv("MODEL"),
		ApiUrl:   os.Getenv("API_URL"),
		ApiToken: os.Getenv("API_TOKEN"),
		Timeout:  timeout,
		Retries:  retries,
	}, nil
}

Это самая простая и базовая загрузка из переменных окружения. Создайте .env файл с полями:

MODEL="qwen/qwen3-30b-a3b:free"
API_URL="https://openrouter.ai/api/v1/chat/completions"
API_TOKEN="ваш ключ(см ниже как получить)"
RETRIES=3
TIMEOUT=60

Получаем информацию о пк

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

func GetSystemInfo() string {
	osName := runtime.GOOS

	shell := "bash"
	if osName == "windows" {
		shell = "PowerShell"
	}

	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: %s\n- User: %s\n- CWD: %s\n",
		osName, shell, username, cwd,
	)
}

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

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

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

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

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

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

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

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

func (a *App) generateContent(messages []Message) (string, error) {
	reqBody := APIRequest{
		Model:    a.config.Model,
		Messages: messages,
	}

	bodyBytes, err := json.Marshal(reqBody)
	if err != nil {
		return "", fmt.Errorf("error encoding JSON: %w", err)
	}

	req, err := http.NewRequest("POST", a.config.ApiUrl, bytes.NewBuffer(bodyBytes))
	if err != nil {
		return "", fmt.Errorf("error creating request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+a.config.ApiToken)

	resp, err := a.client.Do(req)
	if err != nil {
		return "", fmt.Errorf("error executing request: %w", err)
	}
	defer resp.Body.Close()

	respBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("error reading response: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(respBytes))
	}
	var result APIResponse
	if err := json.Unmarshal(respBytes, &result); err != nil {
		return "", fmt.Errorf("error decoding response: %w", err)
	}

	if len(result.Choices) > 0 && result.Choices[0].Message.Content != "" {
		return result.Choices[0].Message.Content, nil
	}
	return "", fmt.Errorf("API did not return content in the response")
}

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

Пошаговый разбор:

  1. Формирование тела запроса
    Создаётся структура APIRequest, содержащая:

    • модель, указанную в конфигурации (a.config.Model)

    • список сообщений, переданных в функцию (messages)

    Эта структура сериализуется в JSON с помощью json.Marshal.

  2. Создание HTTP-запроса
    Сформированный JSON помещается в POST-запрос на адрес a.config.ApiUrl.
    Также добавляются заголовки:

    • Content-Type: application/json

    • Authorization: Bearer <токен> (для доступа к API)

  3. Отправка запроса и получение ответа
    Запрос выполняется через HTTP-клиент (a.client.Do(req)), после чего:

    • тело ответа считывается с помощью io.ReadAll

    • проверяется код ответа: если он не 200 OK, возвращается ошибка с телом ответа

  4. Десериализация и проверка ответа
    Если код ответа — 200, происходит парсинг JSON-ответа в структуру APIResponse. Далее проверяется:

    • Есть ли хотя бы один элемент в result.Choices

    • Есть ли текст в Choices[0].Message.Content

    Если всё в порядке — возвращается сгенерированный текст.

Что возвращает функция:

  • Успех: текст, сгенерированный LLM (например, ChatGPT)

  • Ошибка: если API недоступен, пришёл некорректный ответ или модель ничего не вернула — функция вернёт подробную ошибку

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

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

func ExecuteCommand(command string) (string, error) {
	var cmd *exec.Cmd
	if runtime.GOOS == "windows" {
		cmd = exec.Command("powershell", "-NoProfile", "-Command", command)
	} else {
		cmd = exec.Command("bash", "-c", command)
	}

	output, err := cmd.CombinedOutput()

	return strings.ToValidUTF8(string(output), ""), err
}

Функция executeCommand выполняет указанную строку команды через консоль, собирая весь вывод (включая ошибки), преобразует его в корректную 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, "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("\nInformation:")
	fmt.Println(" • Use a \"!\" before your query to get answers to questions, or just enter your query. Example: !How to create a folder?")
	fmt.Println(" • To agent mode, just enter your query without \"!\". Agent will execute commands and provide reports with results.")
	fmt.Println(" • To exit the program, press Ctrl+C or close the terminal window.")
	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).Print(frames[i%len(frames)])
				time.Sleep(100 * time.Millisecond)
				i++
			}
		}
	}()
	return stop
}

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

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

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

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

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

	fmt.Printf(" %s\n", ErrorColor("╭─[ Error ]"+strings.Repeat("─", width-16)))
	fmt.Printf(" %s %s\n", BorderColor("│"), LabelColor("Error log:"))
	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 summary:"))
		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)))
}

func PrintResultBox(commandOutput, aiSummary string) {
	width := 80

	fmt.Printf(" %s\n", SuccessColor("╭─[ Result ]"+strings.Repeat("─", width-16)))
	fmt.Printf(" %s %s\n", BorderColor("│"), LabelColor("Command output:"))
	for _, line := range strings.Split(strings.TrimSpace(commandOutput), "\n") {
		fmt.Printf(" %s   %s\n", BorderColor("│"), line)
	}

	fmt.Printf(" %s %s\n", BorderColor("│"), BorderColor(strings.Repeat("·", width-4)))
	fmt.Printf(" %s %s\n", BorderColor("│"), AiColor("AI summary:"))
	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 SimpleResultBox(commandOutput string) {
	width := 80

	fmt.Printf(" %s\n", SuccessColor("╭─[ Result ]"+strings.Repeat("─", width-16)))
	fmt.Printf(" %s %s\n", BorderColor("│"), LabelColor("Command output:"))
	for _, line := range strings.Split(strings.TrimSpace(commandOutput), "\n") {
		fmt.Printf(" %s   %s\n", BorderColor("│"), line)
	}
	fmt.Printf(" %s\n", SuccessColor("╰"+strings.Repeat("─", width-2)))
}

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

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

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

func (a *App) prepareAPImessages(systemPrompt, userInput string) []Message {
	messages := make([]Message, 0, len(a.history)+2) //выделяем память
	messages = append(messages, Message{Role: "system", Content: systemPrompt})
	messages = append(messages, a.history...)
	messages = append(messages, Message{Role: "user", Content: userInput})
	return messages
}

Эта функция готовит список сообщений для отправки в API большой языковой модели (LLM).

Теперь создадим обычный чат:

func (a *App) handleSimpleChat(userInput string) {
	spiner := ui.StartSpiner("Thinking...")

	cleanInput := strings.TrimPrefix(userInput, "!")
	messagesToGenerate := a.prepareAPImessages(simpleChatPromptTemplate, cleanInput)

	response, err := a.generateContent(messagesToGenerate)
	if err != nil {
		spiner <- true
		log.Println(ui.ErrorColor(err.Error()))
		return
	}
	spiner <- true
  
    a.history = append(a.history, Message{Role: "user", Content: userInput})
	a.history = append(a.history, Message{Role: "assistant", Content: response})
	ui.SimpleResultBox(response)
}

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

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

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

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

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

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

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

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

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

  6. Сохраняет результат в глобальную историю

  7. Печатает результат в красивой рамке.

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

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

func (a *App) askForConfirmation(command string) bool {
	fmt.Print("Confirm command? [y/n]: ", ui.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) handleAgentMode(userInput string, autoComplete bool) {
	attemptHistory := a.prepareAPImessages(commandGenPromptTemplate, userInput)
	var lastError string
	for i := 0; i < a.config.Retries; i++ {
		spiner := ui.StartSpiner("Command generation...")
		command, err := a.generateContent(attemptHistory)
		spiner <- true

		if err != nil {
			ui.PrintErrorBox(fmt.Sprintf("Command generation error:\n%v", err), "")
			return
		}

		cleanCommand := cleanCommand(command)
		if !autoComplete {
			if !a.askForConfirmation(cleanCommand) {
				log.Println(ui.AiColor("Command cancelled by user."))
				return
			}
		}
		attemptHistory = append(attemptHistory, Message{Role: "assistant", Content: command})
		spinerExecution := ui.StartSpiner("Command execution...")
		commandOutput, err := shell.ExecuteCommand(cleanCommand)
		spinerExecution <- true

		if err == nil {
			spinnerSummary := ui.StartSpiner("Generating summary...")

			summaryPrompt := []Message{
				{
					Role:    "system",
					Content: "You are an AI agent who short explain the result of executing the command, based on the original user query. NEVER USE SMILEYS.",
				},
				{
					Role:    "user",
					Content: fmt.Sprintf(summaryPromptTemplate, userInput, commandOutput),
				},
			}
			summary, err := a.generateContent(summaryPrompt)
			spinnerSummary <- true
			if err != nil {
				ui.PrintErrorBox(fmt.Sprintf("Summary generation error:\n%v", err), "")
				return
			}
			ui.PrintResultBox(commandOutput, summary)
			a.history = append(a.history,
				Message{Role: "user", Content: userInput},
				Message{Role: "assistant", Content: fmt.Sprintf("Command: `%s`\nSummary: %s", command, summary)},
			)
			return
		}
		lastError = commandOutput
		errorFeedback := fmt.Sprintf("This command did not work. Output was:\n%s\nTry another command.", commandOutput)
		attemptHistory = append(attemptHistory, Message{Role: "user", Content: errorFeedback})

		time.Sleep(time.Second)
	}

	spinnerErrorAnalysis := ui.StartSpiner("Generating error analysis...")
	analysis, err := a.generateContent([]Message{
		{
			Role:    "system",
			Content: "You are an AI agent who short explain the result of executing the command, based on the original user query. NEVER USE SMILEYS.",
		},
		{
			Role:    "user",
			Content: fmt.Sprintf(errorAnalysisPromptTemplate, userInput, lastError),
		},
	})
	spinnerErrorAnalysis <- true
	if err != nil {
		ui.PrintErrorBox(fmt.Sprintf("Error analysis generation error:\n%v", err), "")
		return
	}
	ui.PrintErrorBox(fmt.Sprintf("Failed to execute task after %d attempts.", a.config.Retries), analysis)
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

func (a *App) promptUser() (string, error) {
	fmt.Println("╭─" + strings.Repeat("─", 66))
	color.New(color.FgHiWhite).Print("│ > Enter your query: ")
	userInput, err := a.reader.ReadString('\n')
	color.New(color.FgHiWhite).Println("╰" + strings.Repeat("─", 65))
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(userInput), nil
}

func (a *App) CleanHistory() {
	a.history = []Message{
		{
			Role:    "system",
			Content: systemPromptBase + "\n" + shell.GetSystemInfo(),
		},
	}
}

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

func (a *App) Run() {
	ui.PrintHeader()

	for {
		userInput, err := a.promptUser()
		if err != nil {
			fmt.Println("Error reading input:", err)
			continue
		}

		switch userInput {
		case "/exit":
			log.Println(ui.AiColor("exit"))
			return
		case "/clear":
			a.CleanHistory()
			log.Println(ui.SuccessColor("History cleared"))
			continue
		case "/auto-true":
			a.autoComplete = true
			log.Println(ui.SuccessColor("Auto complete enabled"))
			continue
		case "/auto-false":
			a.autoComplete = false
			log.Println(ui.SuccessColor("Auto complete disabled"))
			continue
		default:
			if isCommandPrefixed(userInput) {
				a.handleSimpleChat(userInput)
			} else {
				a.handleAgentMode(userInput, a.autoComplete)
			}
		}

	}
}

func main() {
	cfg, err := config.LoadConfig()
	if err != nil {
		log.Fatal(err)
	}

	client := &http.Client{
		Timeout: time.Duration(cfg.Timeout) * time.Second,
	}

	defer client.CloseIdleConnections()

	agent := app.NewApp(cfg, client)
	agent.Run()
}

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

В методе Run реализован главный цикл CLI-приложения, который:

  • Показывает заголовок при старте.

  • В бесконечном цикле запрашивает ввод пользователя.

  • Обрабатывает специальные команды (/exit, /clear, /auto-true, /auto-false).

  • Для любого другого ввода решает, как его обрабатывать, на основе проверки с помощью isCommandPrefixed.

Роль isCommandPrefixed

Функция isCommandPrefixed проверяет, начинается ли пользовательский ввод с символа !. Этот символ служит индикатором режима чата — когда пользователь хочет получить ответ от AI без генерации и выполнения команд в терминале.

  • Если ввод начинается с ! — запускается обработка в простом чат-режиме (handleSimpleChat).

  • Если нет — запускается основной режим агента, который генерирует и исполняет команды в терминале (handleAgentMode).

Результат

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

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

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

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

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

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

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

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

Комментарии (0)