---
title: "Итоговый проект: CLI-менеджер задач"
description: "Cobra итоговый проект: полноценное cli приложение go пример — менеджер задач tasks с add, list, done, rm, JSON-хранилищем, цветным выводом, версиями и тестами."
course: "Cobra: CLI-приложения на Go"
url: "https://golangmaster.ru/courses/cobra/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Итоговый проект: CLI-менеджер задач

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

Соберём полноценное cli приложение go пример — менеджер задач tasks, в котором сходится весь курс: команды add, list, done и rm, JSON-хранилище в домашнем каталоге, цветной вывод, коды выхода, версия и тесты Execute в памяти. Ни одна деталь здесь не новая: каждая уже разобрана в своём уроке, и финал — это сборка, а не открытие. К концу у репозитория будут чеклист сборки, тесты и два маршрута роста — TUI-режим и структурные логи.

Каталог проекта пуст, в редакторе открыт main.go с парой строк, в терминале мигает приглашение — и на всё про всё двадцать четыре минуты. Это нормальная точка старта итогового проекта: новых знаний он не требует, он требует умения складывать старые в один репозиторий так, чтобы через месяц не было стыдно. Утилита tasks: задачи добавляются командой add, список показывается list с фильтрами, done закрывает задачу по номеру, rm удаляет. Данные лежат в одном JSON-файле в домашнем каталоге, вывод раскрашен, ошибки возвращают честные коды выхода, а вшитая версия отвечает на --version. Готовое cli приложение go пример такого сорта собирается за вечер и живёт в системе годами; весь код ниже вы уже видели по частям — теперь он встретится целиком, и я буду звать предыдущие уроки по именам, а не переписывать их.

### Каркас репозитория: cmd/, internal/tasks и main.go

Раскладка из урока про структуру проекта переносится без изменений, и это лучшая проверка того, что она была не теорией. Модуль называется github.com/you/tasks; внутри main.go на тридцать строк, каталог cmd с одним файлом на команду — root.go, add.go, list.go, done.go, rm.go — плюс app.go с приёмником зависимостей, errors.go с ExitError и colors.go со стилями. Домен живёт в internal/tasks: модель Task, хранилище Store, сентинел ErrNotFound — и ни одного импорта cobra, домен не знает про терминал. Конфигурация — в internal/config, единственном пакете, где живёт viper. Связка — структура App: фабрики команд принимают её аргументом и замыкают на себя, команды тянут из неё Store и Config и не лезут в глобальные переменные. Если ваша раскладка после двадцати четырёх уроков получилась другой — это не провал: раскладка обслуживает проект, а не наоборот. Обоснование каждой полки — в [уроке про структуру проекта](/courses/cobra/lessons/21-struktura-proekta/).

### main.go: тридцать строк, держащих весь проект

```go
package main

import (
        "context"
        "errors"
        "fmt"
        "os"
        "os/signal"
        "syscall"

        "github.com/you/tasks/cmd"
        "github.com/you/tasks/internal/config"
        "github.com/you/tasks/internal/tasks"
)

var (
        version = "dev"
        commit  = "unknown"
)

func main() {
        cfg, err := config.Load()
        if err != nil {
                fmt.Fprintln(os.Stderr, err)
                os.Exit(2)
        }

        ctx, stop := signal.NotifyContext(context.Background(),
                os.Interrupt, syscall.SIGTERM)
        defer stop()

        app := cmd.NewApp(tasks.NewStore(cfg.TasksFile), cfg)
        root := cmd.NewRootCmd(app)
        root.Version = version
        if err := root.ExecuteContext(ctx); err != nil {
                var ee *cmd.ExitError
                if errors.As(err, &ee) {
                        os.Exit(ee.Code)
                }
                os.Exit(1)
        }
}
```

Точка входа держит три контракта курса. Конфиг: config.Load собирает viper-источники и возвращает структуру с путём хранилища, ошибка даёт код 2 — это проблема использования, а не сбой. Сигналы: NotifyContext из урока про graceful shutdown отменяет контекст по Ctrl+C, ExecuteContext доставляет его каждой RunE. Коды выхода: errors.As разбирает ExitError и отдаёт словарю право голоса, общий провал — единица. Поле Version заполнит линковщик, commit ждёт релиза.

### Хранилище: JSON в домашнем каталоге

Путь к данным решается одной функцией stdlib: os.UserConfigDir() возвращает ~/.config на Linux, ~/Library/Application Support на macOS и %AppData% на Windows, и хранилище ложится в подкаталог tasks рядом с config.yaml. Пуристы XDG отправят данные в ~/.local/share через os.UserHomeDir — по букве стандарта они правы, отделять данные от настроек положено; я сознательно беру UserConfigDir, потому что одна функция stdlib вместо ручной склейки путей для утилиты на триста строк — честная сделка, а не лень. Контракт Store прост: Load читает файл и молчит, если его нет, — первый запуск не ошибка, а пустой список; Save маршалит срез с отступами, создаёт каталог и пишет с правами 0o600, потому что в задачах бывают личные вещи. Про формат — спорное мнение: пока задач меньше нескольких сотен, JSON с MarshalIndent читается глазами и правится руками, и я не тяну SQLite в утилиту, которая обязана собираться за секунду; порог, когда плоский файл перестаёт поспевать, — тысячи записей и частичные обновления на каждый чих, тогда и переедем.

### internal/tasks/store.go: Load, Save, Done

```go
var ErrNotFound = errors.New("задача не найдена")

type Store struct {
        path  string
        items []Task
        next  int
}

func NewStore(path string) *Store { return &Store{path: path, next: 1} }

func (s *Store) Load() error {
        data, err := os.ReadFile(s.path)
        if errors.Is(err, os.ErrNotExist) {
                return nil // первый запуск: пусто — это не ошибка
        }
        if err != nil {
                return fmt.Errorf("читаю %s: %w", s.path, err)
        }
        return json.Unmarshal(data, &s.items)
}

func (s *Store) Save() error {
        data, err := json.MarshalIndent(s.items, "", "  ")
        if err != nil {
                return err
        }
        if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
                return err
        }
        return os.WriteFile(s.path, data, 0o600)
}

func (s *Store) Done(id int) error {
        for i := range s.items {
                if s.items[i].ID == id {
                        s.items[i].Status = StatusDone
                        return s.Save()
                }
        }
        return fmt.Errorf("done %d: %w", id, ErrNotFound)
}
```

Load прощает только отсутствие файла: errors.Is различает «пусто» и «сломано», битый JSON вернёт ошибку с путём через %w. Save создаёт каталог с 0o700 и файл с 0o600 — разница в том, кто может листать каталог и кто читать данные. Done меняет статус по номеру и оборачивает промах в ErrNotFound; этот сентинел вы увидите снова в тестах, где errors.Is отличит его от сбоя диска.

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

**Вопрос:** Первый запуск tasks на чистой машине: файла tasks.json ещё нет. Что вернёт Store.Load()?

- Ошибку os.ErrNotExist — утилита откажется работать
- ✅ nil и пустой список: отсутствие файла — нормальное состояние
- Автоматически создаст файл с пустым срезом внутри
- ConfigFileNotFoundError — viper не нашёл конфиг

Ветвь errors.Is(err, os.ErrNotExist) возвращает nil: пустое хранилище — легальный старт, файл появится при первом Save вместе с каталогом. Создавать файл при чтении — плохая привычка: list тогда нельзя запустить в каталоге без прав на запись. ConfigFileNotFoundError — ошибка другого слоя: это viper и конфиг, а не хранилище.

### Команды add, done, rm и коды выхода

Команда add берёт ровно один аргумент — текст задачи — через Args: cobra.ExactArgs(1) и печатает строку вида добавлено: 3, чтобы номер был виден сразу же. Done и rm ищут запись по номеру; промах оборачивается fmt.Errorf с %w вокруг ErrNotFound, и main переводит это в код 1 — общий провал. Словарь кодов из урока про ошибки живёт в документации, а не в голове: 0 — успех, 1 — провал, 2 — ошибка использования вроде неизвестного статуса в --status, 3 — частичный успех, пригодится будущей команде sync. Rm опаснее остальных: в терминале он спрашивает подтверждение через диалог из урока про интерактивный ввод, в скриптах требует флаг --yes, а на отказ возвращает сентинел errDeclined — тест из урока про тестирование ловит его через errors.Is, не читая текст. На корне включён SilenceUsage: опечатка во флаге не должна вываливать страницу команд вслед за сообщением об ошибке — пользователь уже знает, где --help.

### list: флаги --status и --all, цветной вывод

Семантика list — первое проектное решение, которое стоит проговорить вслух. По умолчанию команда показывает только открытые задачи: как git status молчит про чистые файлы, так и list не печатает закрытое — за статусом глаза ходят к тому, что осталось сделать. Флаг --status done покажет выполненное, --status todo повторит умолчание явно, --all снимает фильтр целиком. Значение --status валидируется до обращения к хранилищу: что-то кроме todo и done возвращает ExitError с кодом 2 — ошибка использования ловится на входе, а не после чтения файла. Вывод идёт через text/tabwriter: три колонки — номер, отметка, текст — выравниваются по самой широкой ячейке, и таблица не расползается на длинных заголовках. Цвет берётся из cmd/colors.go, где fatih/color объявляет okStyle и todoStyle один раз: галочка зелёная, точка серая, и это единственное место проекта, знающее про ANSI-байты. Библиотека сама проверяет isatty и NO_COLOR, а глобальный флаг --color со значениями auto, always и never из урока про цветной вывод управляет color.NoColor, когда автоматика угадала не то.

### cmd/list.go: валидация, фильтр, таблица

```go
func newListCmd(app *App) *cobra.Command {
        var status string
        var all bool

        cmd := &cobra.Command{
                Use:   "list",
                Short: "Показать задачи",
                RunE: func(cmd *cobra.Command, args []string) error {
                        if status != "" && status != string(tasks.StatusTodo) &&
                                status != string(tasks.StatusDone) {
                                return &ExitError{Code: 2,
                                        Err: fmt.Errorf("--status: неизвестный статус %q", status)}
                        }
                        w := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
                        for _, t := range app.Store.List(status, all) {
                                mark := okStyle.Sprint("✓")
                                if t.Status != tasks.StatusDone {
                                        mark = todoStyle.Sprint("·")
                                }
                                fmt.Fprintf(w, "%d\t%s\t%s\n", t.ID, mark, t.Text)
                        }
                        return w.Flush()
                },
        }
        cmd.Flags().StringVar(&status, "status", "", "фильтр: todo или done")
        cmd.Flags().BoolVar(&all, "all", false, "показать всё, включая выполненное")
        return cmd
}
```

Валидация статуса идёт первой строкой RunE и не стоит ни одного вызова хранилища. Фильтрация живёт в Store.List(status, all), а не в команде: домен умеет отбирать, cmd умеет показывать — та же граница слоёв, что в уроке про структуру. Стили Sprint возвращают строку с кодами или без: решение о цвете приняла библиотека при старте. Flush обязателен — без него tabwriter удержит выровненные строки в буфере, и list напечатает пустоту.

### Проверка: цвет в пайпе

**Вопрос:** Вывод уходит в пайп: tasks list | grep кофе. Что получит grep?

- Строки с ANSI-кодами: цвет сохраняется в любом выводе
- ✅ Чистые строки без кодов: color увидел не-терминал и включил NoColor
- Пустой вывод: утилита отказывается писать в пайп без --color
- Ошибку broken pipe и код выхода 3

fatih/color при инициализации проверяет stdout через isatty и уважает NO_COLOR: файл и пайп получают текст без единого escape-байта. Принудительно вернуть цвет можно флагом --color always — например, для less -R, — но по умолчанию пайп чище, и grep не ловит коды как часть искомой строки.

### Что окажется в tasks.json

```go
// internal/tasks/model.go
type Task struct {
        ID     int    `json:"id"`
        Text   string `json:"text"`
        Status Status `json:"status"`
}

data, _ := json.MarshalIndent([]Task{{ID: 1, Text: "звонок", Status: StatusDone}}, "", "  ")
fmt.Println(string(data))
```

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

- ✅ Многострочный JSON с ключами id, text, status в нижнем регистре
- JSON с ключами ID, Text, Status — именами полей Go
- Одну строку без отступов: MarshalIndent работает как Marshal
- Ошибку: строковый тип Status нельзя маршалить

Теги структуры задают имена ключей: в файл попадают id, text, status в нижнем регистре, а не имена полей Go. MarshalIndent форматирует с отступами — файл читается глазами, это часть контракта хранилища. Status — строковый тип, для json он обычная строка. В полном Task проекта есть ещё created_at с тегом и временем создания.

### Версия и Ctrl+C: два маленьких блока в main

Версия в проекте занимает три строки исходника и одну команду сборки. В main объявлены var version = "dev" и commit, поле Version корня заполняется при старте — Cobra сама добавляет команду version и флаг --version с выводом tasks version dev. Релизная сборка подменяет значения линковщиком: go build -ldflags "-X 'main.version=v1.4.2' -X 'main.commit=9f3ac12'" — и утилита начинает отвечать честно. Отдельную команду version писать не нужно: всё уже сделала Cobra, шаблон вывода при желании меняется через SetVersionTemplate. Сигналы — вторая половина блока: NotifyContext превращает Ctrl+C в отмену контекста, ExecuteContext доставляет её каждой RunE, а defer stop() возвращает терминалу контроль после второго нажатия. Для менеджера задач это страховка про запас: add и list отрабатывают за миллисекунды, ловить в них нечего. Но привычка переносится без переделок: когда в задачах появится sync с сервером — она же даст коду 3 частичного успеха работу, — контекст уже будет протянут куда надо, и graceful shutdown сведётся к проверке ctx.Done() в цикле закачки.

### Тесты: Execute в памяти, хранилище во временном каталоге

Каркас из урока про тестирование доезжает до финала почти без изменений, с одной поправкой на DI: runCmd принимает App, потому что корень создаётся фабрикой NewRootCmd(app) на каждый вызов — персистентные флаги pflag не переползают между тестами. Хранилище в тестах указывает на t.TempDir(): каталог тестовый раннер удалит сам, и ни один тест не касается реальных задач пользователя. Сценарий пробегает полный жизненный цикл — add, list, done, list --status done — и на каждом шаге проверяет буфер через strings.Contains. Валидация --status ложится в табличный тест: пустой статус, todo, мусорное значение — три кейса с ожидаемыми wantErr и wantOut. Длинный вывод таблицы закрывают golden-файлы из cmd/testdata: эталон обновляется флагом -update осознанно, дифф читается в ревью как правка интерфейса. Прогон go test ./... требует живого Go с модулями проекта — песочница курса тестовый раннер и файловую систему не эмулирует, поэтому сборку и тесты вы делаете локально, в своём терминале.

### cmd/root_test.go: сценарий на весь жизненный цикл

```go
func newTestApp(t *testing.T) *App {
        t.Helper()
        path := filepath.Join(t.TempDir(), "tasks.json")
        return NewApp(tasks.NewStore(path), config.Config{})
}

func TestAdd_Done_Filter(t *testing.T) {
        app := newTestApp(t)

        if _, err := runCmd(app, "add", "купить кофе"); err != nil {
                t.Fatalf("add: %v", err)
        }
        out, err := runCmd(app, "list")
        if err != nil || !strings.Contains(out, "кофе") {
                t.Fatalf("list: err=%v out=%q", err, out)
        }
        if _, err := runCmd(app, "done", "1"); err != nil {
                t.Fatalf("done: %v", err)
        }
        out, _ = runCmd(app, "list", "--status", "done")
        if !strings.Contains(out, "кофе") {
                t.Errorf("выполненная не видна в --status done:\n%s", out)
        }
}
```

newTestApp прячет рутину: t.TempDir, путь tasks.json внутри, конфиг-заглушка. Тест идёт по сценарию пользователя, а не по функциям пакета: только Execute проходит тот же путь, что и терминал, с парсингом флагов и валидацией. Проверки через Contains устойчивы к правкам форматирования — таблица меняется, смысл нет. Один такой тест заменяет прогон руками после каждой правки.

> **Тест, который стёр 214 задач.** Ранняя версия этого проекта ловила баг на себе: хелпер newApp() читал конфиг по умолчанию, брал настоящий путь хранилища, и табличный тест done 1, done 2, done 3 за секунду закрыл двести четырнадцать реальных задач — с осмысленными текстами за три года. Резервной копии не было, JSON истории не хранит. Лечение заняло час, привычка — навсегда: App в тестах строится только с путём во временный каталог, настоящий конфиг читает исключительно main, а правило ревью звучит жёстко — тест, в котором не видно, откуда взялся путь хранилища, считается опасным, пока не доказано обратное. Файловые утилиты прощают всё, кроме одного прогона по живым данным.

### Соберите Save по строкам

Впишите маршал с отступами, создание каталога и права файла.

```go
func (s *Store) Save() error {
        data, err := json.___(s.items, "", "  ")
        if err != nil {
                return err
        }
        if err := os.___(filepath.Dir(s.path), 0o700); err != nil {
                return err
        }
        return os.WriteFile(s.path, data, 0o___)
}
```

Подсказка: Функция пакета json, форматирующая с отступами; функция os, создающая каталог рекурсивно; права файла в восьмеричном литерале 0o___.

Правильные ответы в пропусках: `MarshalIndent`, `MkdirAll`, `600`

### Соберите config.Load по шагам

Расставьте тело config.Load от создания viper до возврата структуры.

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

1. `v := viper.New()`
2. `v.SetConfigFile(cfgPath)`
3. `if err := v.ReadInConfig(); err != nil { return Config{}, err }`
4. `v.SetEnvPrefix("tasks")`
5. `v.AutomaticEnv()`
6. `return Config{TasksFile: v.GetString("store.file")}, nil`

### Чеклист сборки проекта

- go mod init github.com/you/tasks && go mod tidy — в go.sum ложатся cobra, viper и fatih/color
- gofmt -l . и go vet ./... — оба молчат
- go test ./... — зелёный: табличные кейсы, golden-файлы, сценарий add–done–list
- go build -o tasks . — ./tasks --version печатает tasks version dev
- go build -ldflags "-X 'main.version=v0.1.0'" — версия вшита, ответ меняется
- ./tasks add "первая задача" — каталог ~/.config/tasks создан, файл с правами 0o600
- ./tasks list | cat — вывод без ANSI-кодов: пайп остался чистым
- ./tasks done 99; echo $? — единица: ErrNotFound дошёл до кода выхода
- ./tasks completion zsh — скрипт автодополнения генерируется, как в уроке про completion

### Карточки: детали проекта

- **Store** — Хранилище tasks: путь к файлу, срез задач, счётчик next для ID. Load прощает отсутствие файла, Save пишет MarshalIndent с MkdirAll и правами 0o600.
- **ExitError** — Тип ошибки с полем Code из урока про коды выхода: RunE возвращает его, main разбирает errors.As и зовёт os.Exit(ee.Code). 1 — провал, 2 — ошибка использования, 3 — частичный успех.
- **ErrNotFound** — Сентинел пакета internal/tasks: done и rm не нашли номер. Оборачивается fmt.Errorf с %w — errors.Is в тестах отличает его от сбоя хранилища при одном и том же коде выхода.
- **os.UserConfigDir** — Каталог настроек платформы: ~/.config на Linux, ~/Library/Application Support на macOS, %AppData% на Windows. Рядом лежит tasks/tasks.json — весь перенос данных это копирование одного файла.
- **NoColor** — Флаг fatih/color: при старте библиотека проверяет isatty и NO_COLOR, и вывод в пайп остаётся чистым текстом. Управляется и глобальным флагом --color auto|always|never.
- **t.TempDir** — Каталог на время теста с автозачисткой: хранилище теста указывает туда, и реальные задачи пользователя в безопасности. Тест без видимого пути хранилища — опасный тест.

### Поздравление и куда дальше

Если чеклист зелёный — на диске лежит законченная утилита, а не учебный пример: с конфигом, цветами, кодами выхода, версией и тестами. Курс Cobra пройден целиком, и это видно по репозиторию — поздравляю. Идеи доработок лежат на поверхности: дедлайны у задач с подсветкой просроченных, поиск search по подстроке, экспорт в Markdown, синхронизация между машинами через git — она же оживит код 3 частичного успеха, а когда записей станет много, хранилище переедет в SQLite. Два направления уже ждут в соседних курсах. Первый — TUI-режим: тот же list, но интерактивной таблицей с курсором, поиском и статус-баром — это [курс Bubble Tea](/courses/bubbletea/), и ваш домен из internal/tasks переедет туда без единой правки, потому что не знает про терминал. Второй — структурные логи для sync и отладки: [курс Zap](/courses/zap/) покажет, как заменить fmt.Fprintln на уровни и поля. Обзор всех уроков — на странице [Cobra](/courses/cobra/). Соберите бинарник локально, положите в PATH — и пусть tasks живёт в вашем терминале.

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

- Репозиторий tasks собирает весь курс: cmd/ с командами, internal/tasks с доменом, main.go на тридцать строк.
- Хранилище — JSON в ~/.config/tasks/tasks.json: Load прощает отсутствие файла, Save создаёт каталог и пишет с правами 0o600.
- Коды выхода — контракт для скриптов: 0 успех, 1 провал, 2 ошибка использования, 3 частичный успех; путь через ExitError и errors.As.
- Цвет через fatih/color: стили объявлены один раз, в пайпе отключаются сами; --status и --all фильтруют list.
- Версия приходит ldflags-ом в var version; сигналы — NotifyContext и ExecuteContext из урока про graceful shutdown.
- Тесты гоняют Execute в памяти: runCmd с App, t.TempDir для хранилища, табличные сценарии и golden-файлы.
- Дальше — TUI на Bubble Tea и структурные логи Zap: CLI остаётся ядром, интерфейсы наращиваются слоями.

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

Довершите утилиту командой search: tasks search TEXT фильтрует задачи по подстроке в тексте, уважает --status и --all, печатает через tabwriter и возвращает код 1 при пустом результате. Добавьте табличный тест на три кейса search во временном каталоге и соберите релизную версию с -ldflags — проверьте, что --version отвечает номером.

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

### Где tasks хранит задачи и как перенести их на другую машину?

В JSON-файле по пути os.UserConfigDir(): ~/.config/tasks/tasks.json на Linux, ~/Library/Application Support/tasks на macOS, %AppData% на Windows. Перенос — копирование одного файла: это и преимущество плоского хранилища, и его потолок. Путь переопределяется ключом store.file в конфиге или переменной TASKS_STORE_FILE — лестница приоритетов из урока про конфигурацию работает до конца.

### Почему list по умолчанию скрывает выполненные задачи?

Это проектное решение: команда, которую вызывают чаще всего, должна показывать остаток работы — как git status показывает изменения, а не всю историю. Выполненное доступно через --status done, полный список — через --all. Если ваша привычка другая, умолчание меняется в одном месте, Store.List, и тест на жизненный цикл это отследит.

### Как поставить tasks в систему одной командой?

go install github.com/you/tasks@latest положит бинарник в GOBIN — обычно ~/go/bin; убедитесь, что каталог в PATH. Релизную версию собирайте с -ldflags и тегом, а выпуск на несколько платформ автоматизирует goreleaser из урока про сборку: архивы, контрольные суммы и формула Homebrew делаются одним тегом.
