esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

2026-03-01-go-rewrite-plan.md (62831B)


      1 # esync Go Rewrite — Implementation Plan
      2 
      3 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
      4 
      5 **Goal:** Rewrite esync from Python to Go with a Bubbletea TUI, Cobra CLI, and Viper-based TOML configuration.
      6 
      7 **Architecture:** Cobra CLI dispatches to subcommands. `esync sync` launches either a Bubbletea TUI (default) or daemon mode. fsnotify watches files, debouncer batches events, syncer executes rsync. Viper loads TOML config with a search path.
      8 
      9 **Tech Stack:** Go 1.22+, Cobra, Viper, Bubbletea, Lipgloss, fsnotify, rsync (external)
     10 
     11 ---
     12 
     13 ### Task 1: Project Scaffolding
     14 
     15 **Files:**
     16 - Create: `main.go`
     17 - Create: `go.mod`
     18 - Create: `cmd/root.go`
     19 - Create: `internal/config/config.go`
     20 - Create: `internal/syncer/syncer.go`
     21 - Create: `internal/watcher/watcher.go`
     22 - Create: `internal/tui/app.go`
     23 - Create: `internal/logger/logger.go`
     24 
     25 **Step 1: Remove Python source files**
     26 
     27 Delete the Python package and build files (we're on a feature branch):
     28 ```bash
     29 rm -rf esync/ pyproject.toml uv.lock .python-version
     30 ```
     31 
     32 **Step 2: Initialize Go module**
     33 
     34 ```bash
     35 go mod init github.com/eloualiche/esync
     36 ```
     37 
     38 **Step 3: Create directory structure**
     39 
     40 ```bash
     41 mkdir -p cmd internal/config internal/syncer internal/watcher internal/tui internal/logger
     42 ```
     43 
     44 **Step 4: Create minimal main.go**
     45 
     46 ```go
     47 package main
     48 
     49 import "github.com/eloualiche/esync/cmd"
     50 
     51 func main() {
     52 	cmd.Execute()
     53 }
     54 ```
     55 
     56 **Step 5: Create root command stub**
     57 
     58 ```go
     59 // cmd/root.go
     60 package cmd
     61 
     62 import (
     63 	"fmt"
     64 	"os"
     65 
     66 	"github.com/spf13/cobra"
     67 )
     68 
     69 var cfgFile string
     70 
     71 var rootCmd = &cobra.Command{
     72 	Use:   "esync",
     73 	Short: "File synchronization tool using rsync",
     74 	Long:  "A file sync tool that watches for changes and automatically syncs them to a remote destination using rsync.",
     75 }
     76 
     77 func Execute() {
     78 	if err := rootCmd.Execute(); err != nil {
     79 		fmt.Fprintln(os.Stderr, err)
     80 		os.Exit(1)
     81 	}
     82 }
     83 
     84 func init() {
     85 	rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path")
     86 }
     87 ```
     88 
     89 **Step 6: Install dependencies and verify build**
     90 
     91 ```bash
     92 go get github.com/spf13/cobra
     93 go get github.com/spf13/viper
     94 go get github.com/fsnotify/fsnotify
     95 go get github.com/charmbracelet/bubbletea
     96 go get github.com/charmbracelet/lipgloss
     97 go mod tidy
     98 go build ./...
     99 ```
    100 
    101 **Step 7: Commit**
    102 
    103 ```bash
    104 git add -A
    105 git commit -m "feat: scaffold Go project with Cobra root command"
    106 ```
    107 
    108 ---
    109 
    110 ### Task 2: Configuration Package
    111 
    112 **Files:**
    113 - Create: `internal/config/config.go`
    114 - Create: `internal/config/config_test.go`
    115 
    116 **Step 1: Write failing tests for config structs and loading**
    117 
    118 ```go
    119 // internal/config/config_test.go
    120 package config
    121 
    122 import (
    123 	"os"
    124 	"path/filepath"
    125 	"testing"
    126 )
    127 
    128 func TestLoadConfig(t *testing.T) {
    129 	dir := t.TempDir()
    130 	tomlPath := filepath.Join(dir, "esync.toml")
    131 
    132 	content := []byte(`
    133 [sync]
    134 local = "./src"
    135 remote = "user@host:/deploy"
    136 interval = 1
    137 
    138 [settings]
    139 watcher_debounce = 500
    140 initial_sync = true
    141 ignore = ["*.log", "*.tmp"]
    142 
    143 [settings.rsync]
    144 archive = true
    145 compress = true
    146 backup = true
    147 backup_dir = ".rsync_backup"
    148 progress = true
    149 ignore = [".git/", "node_modules/"]
    150 
    151 [settings.log]
    152 file = "/tmp/esync.log"
    153 format = "json"
    154 `)
    155 	os.WriteFile(tomlPath, content, 0644)
    156 
    157 	cfg, err := Load(tomlPath)
    158 	if err != nil {
    159 		t.Fatalf("unexpected error: %v", err)
    160 	}
    161 	if cfg.Sync.Local != "./src" {
    162 		t.Errorf("expected local=./src, got %s", cfg.Sync.Local)
    163 	}
    164 	if cfg.Sync.Remote != "user@host:/deploy" {
    165 		t.Errorf("expected remote=user@host:/deploy, got %s", cfg.Sync.Remote)
    166 	}
    167 	if cfg.Settings.WatcherDebounce != 500 {
    168 		t.Errorf("expected debounce=500, got %d", cfg.Settings.WatcherDebounce)
    169 	}
    170 	if !cfg.Settings.InitialSync {
    171 		t.Error("expected initial_sync=true")
    172 	}
    173 	if len(cfg.Settings.Ignore) != 2 {
    174 		t.Errorf("expected 2 ignore patterns, got %d", len(cfg.Settings.Ignore))
    175 	}
    176 	if !cfg.Settings.Rsync.Archive {
    177 		t.Error("expected rsync archive=true")
    178 	}
    179 	if cfg.Settings.Log.Format != "json" {
    180 		t.Errorf("expected log format=json, got %s", cfg.Settings.Log.Format)
    181 	}
    182 }
    183 
    184 func TestLoadConfigWithSSH(t *testing.T) {
    185 	dir := t.TempDir()
    186 	tomlPath := filepath.Join(dir, "esync.toml")
    187 
    188 	content := []byte(`
    189 [sync]
    190 local = "./src"
    191 remote = "/deploy"
    192 
    193 [sync.ssh]
    194 host = "example.com"
    195 user = "deploy"
    196 port = 22
    197 identity_file = "~/.ssh/id_ed25519"
    198 interactive_auth = true
    199 `)
    200 	os.WriteFile(tomlPath, content, 0644)
    201 
    202 	cfg, err := Load(tomlPath)
    203 	if err != nil {
    204 		t.Fatalf("unexpected error: %v", err)
    205 	}
    206 	if cfg.Sync.SSH == nil {
    207 		t.Fatal("expected SSH config to be set")
    208 	}
    209 	if cfg.Sync.SSH.Host != "example.com" {
    210 		t.Errorf("expected host=example.com, got %s", cfg.Sync.SSH.Host)
    211 	}
    212 	if cfg.Sync.SSH.User != "deploy" {
    213 		t.Errorf("expected user=deploy, got %s", cfg.Sync.SSH.User)
    214 	}
    215 	if cfg.Sync.SSH.IdentityFile != "~/.ssh/id_ed25519" {
    216 		t.Errorf("expected identity_file, got %s", cfg.Sync.SSH.IdentityFile)
    217 	}
    218 }
    219 
    220 func TestLoadConfigDefaults(t *testing.T) {
    221 	dir := t.TempDir()
    222 	tomlPath := filepath.Join(dir, "esync.toml")
    223 
    224 	content := []byte(`
    225 [sync]
    226 local = "./src"
    227 remote = "./dst"
    228 `)
    229 	os.WriteFile(tomlPath, content, 0644)
    230 
    231 	cfg, err := Load(tomlPath)
    232 	if err != nil {
    233 		t.Fatalf("unexpected error: %v", err)
    234 	}
    235 	if cfg.Settings.WatcherDebounce != 500 {
    236 		t.Errorf("expected default debounce=500, got %d", cfg.Settings.WatcherDebounce)
    237 	}
    238 	if cfg.Settings.Rsync.Archive != true {
    239 		t.Error("expected default archive=true")
    240 	}
    241 }
    242 
    243 func TestIsRemote(t *testing.T) {
    244 	tests := []struct {
    245 		remote string
    246 		want   bool
    247 	}{
    248 		{"user@host:/path", true},
    249 		{"host:/path", true},
    250 		{"./local/path", false},
    251 		{"/absolute/path", false},
    252 		{"C:/windows/path", false},
    253 	}
    254 	for _, tt := range tests {
    255 		cfg := &Config{Sync: SyncSection{Remote: tt.remote}}
    256 		if got := cfg.IsRemote(); got != tt.want {
    257 			t.Errorf("IsRemote(%q) = %v, want %v", tt.remote, got, tt.want)
    258 		}
    259 	}
    260 }
    261 
    262 func TestFindConfigFile(t *testing.T) {
    263 	dir := t.TempDir()
    264 	tomlPath := filepath.Join(dir, "esync.toml")
    265 	os.WriteFile(tomlPath, []byte("[sync]\nlocal = \".\"\nremote = \".\"\n"), 0644)
    266 
    267 	found := FindConfigIn([]string{tomlPath})
    268 	if found != tomlPath {
    269 		t.Errorf("expected %s, got %s", tomlPath, found)
    270 	}
    271 }
    272 
    273 func TestFindConfigFileNotFound(t *testing.T) {
    274 	found := FindConfigIn([]string{"/nonexistent/esync.toml"})
    275 	if found != "" {
    276 		t.Errorf("expected empty string, got %s", found)
    277 	}
    278 }
    279 ```
    280 
    281 **Step 2: Run tests to verify they fail**
    282 
    283 ```bash
    284 cd internal/config && go test -v
    285 ```
    286 Expected: compilation errors (types don't exist yet)
    287 
    288 **Step 3: Implement config package**
    289 
    290 ```go
    291 // internal/config/config.go
    292 package config
    293 
    294 import (
    295 	"fmt"
    296 	"os"
    297 	"path/filepath"
    298 	"regexp"
    299 	"strings"
    300 
    301 	"github.com/spf13/viper"
    302 )
    303 
    304 // SSHConfig holds SSH connection settings.
    305 type SSHConfig struct {
    306 	Host            string `mapstructure:"host"`
    307 	User            string `mapstructure:"user"`
    308 	Port            int    `mapstructure:"port"`
    309 	IdentityFile    string `mapstructure:"identity_file"`
    310 	InteractiveAuth bool   `mapstructure:"interactive_auth"`
    311 }
    312 
    313 // SyncSection holds source and destination paths.
    314 type SyncSection struct {
    315 	Local    string     `mapstructure:"local"`
    316 	Remote   string     `mapstructure:"remote"`
    317 	Interval int        `mapstructure:"interval"`
    318 	SSH      *SSHConfig `mapstructure:"ssh"`
    319 }
    320 
    321 // RsyncSettings holds rsync-specific options.
    322 type RsyncSettings struct {
    323 	Archive   bool     `mapstructure:"archive"`
    324 	Compress  bool     `mapstructure:"compress"`
    325 	Backup    bool     `mapstructure:"backup"`
    326 	BackupDir string   `mapstructure:"backup_dir"`
    327 	Progress  bool     `mapstructure:"progress"`
    328 	ExtraArgs []string `mapstructure:"extra_args"`
    329 	Ignore    []string `mapstructure:"ignore"`
    330 }
    331 
    332 // LogSettings holds logging configuration.
    333 type LogSettings struct {
    334 	File   string `mapstructure:"file"`
    335 	Format string `mapstructure:"format"`
    336 }
    337 
    338 // Settings holds all application settings.
    339 type Settings struct {
    340 	WatcherDebounce int           `mapstructure:"watcher_debounce"`
    341 	InitialSync     bool          `mapstructure:"initial_sync"`
    342 	Ignore          []string      `mapstructure:"ignore"`
    343 	Rsync           RsyncSettings `mapstructure:"rsync"`
    344 	Log             LogSettings   `mapstructure:"log"`
    345 }
    346 
    347 // Config is the top-level configuration.
    348 type Config struct {
    349 	Sync     SyncSection `mapstructure:"sync"`
    350 	Settings Settings    `mapstructure:"settings"`
    351 }
    352 
    353 // IsRemote returns true if the remote target is an SSH destination.
    354 func (c *Config) IsRemote() bool {
    355 	if c.Sync.SSH != nil && c.Sync.SSH.Host != "" {
    356 		return true
    357 	}
    358 	return isRemotePath(c.Sync.Remote)
    359 }
    360 
    361 // isRemotePath checks if a path string looks like user@host:/path or host:/path.
    362 func isRemotePath(path string) bool {
    363 	if len(path) >= 2 && path[1] == ':' && (path[0] >= 'A' && path[0] <= 'Z' || path[0] >= 'a' && path[0] <= 'z') {
    364 		return false // Windows drive letter
    365 	}
    366 	re := regexp.MustCompile(`^(?:[^@]+@)?[^/:]+:.+$`)
    367 	return re.MatchString(path)
    368 }
    369 
    370 // AllIgnorePatterns returns combined ignore patterns from settings and rsync.
    371 func (c *Config) AllIgnorePatterns() []string {
    372 	combined := make([]string, 0, len(c.Settings.Ignore)+len(c.Settings.Rsync.Ignore))
    373 	combined = append(combined, c.Settings.Ignore...)
    374 	combined = append(combined, c.Settings.Rsync.Ignore...)
    375 	return combined
    376 }
    377 
    378 // Load reads and parses a TOML config file.
    379 func Load(path string) (*Config, error) {
    380 	v := viper.New()
    381 	v.SetConfigFile(path)
    382 	v.SetConfigType("toml")
    383 
    384 	// Defaults
    385 	v.SetDefault("sync.interval", 1)
    386 	v.SetDefault("settings.watcher_debounce", 500)
    387 	v.SetDefault("settings.initial_sync", false)
    388 	v.SetDefault("settings.rsync.archive", true)
    389 	v.SetDefault("settings.rsync.compress", true)
    390 	v.SetDefault("settings.rsync.backup", false)
    391 	v.SetDefault("settings.rsync.backup_dir", ".rsync_backup")
    392 	v.SetDefault("settings.rsync.progress", true)
    393 	v.SetDefault("settings.log.format", "text")
    394 
    395 	if err := v.ReadInConfig(); err != nil {
    396 		return nil, fmt.Errorf("reading config: %w", err)
    397 	}
    398 
    399 	var cfg Config
    400 	if err := v.Unmarshal(&cfg); err != nil {
    401 		return nil, fmt.Errorf("parsing config: %w", err)
    402 	}
    403 
    404 	if cfg.Sync.Local == "" {
    405 		return nil, fmt.Errorf("sync.local is required")
    406 	}
    407 	if cfg.Sync.Remote == "" {
    408 		return nil, fmt.Errorf("sync.remote is required")
    409 	}
    410 
    411 	return &cfg, nil
    412 }
    413 
    414 // FindConfigFile searches default locations for a config file.
    415 func FindConfigFile() string {
    416 	home, _ := os.UserHomeDir()
    417 	paths := []string{
    418 		filepath.Join(".", "esync.toml"),
    419 		filepath.Join(home, ".config", "esync", "config.toml"),
    420 		"/etc/esync/config.toml",
    421 	}
    422 	return FindConfigIn(paths)
    423 }
    424 
    425 // FindConfigIn searches the given paths for the first existing file.
    426 func FindConfigIn(paths []string) string {
    427 	for _, p := range paths {
    428 		if _, err := os.Stat(p); err == nil {
    429 			return p
    430 		}
    431 	}
    432 	return ""
    433 }
    434 
    435 // DefaultTOML returns a default config as a TOML string.
    436 func DefaultTOML() string {
    437 	return strings.TrimSpace(`
    438 [sync]
    439 local = "."
    440 remote = "./remote"
    441 interval = 1
    442 
    443 # [sync.ssh]
    444 # host = "example.com"
    445 # user = "username"
    446 # port = 22
    447 # identity_file = "~/.ssh/id_ed25519"
    448 # interactive_auth = true
    449 
    450 [settings]
    451 watcher_debounce = 500
    452 initial_sync = false
    453 ignore = ["*.log", "*.tmp", ".env"]
    454 
    455 [settings.rsync]
    456 archive = true
    457 compress = true
    458 backup = false
    459 backup_dir = ".rsync_backup"
    460 progress = true
    461 extra_args = []
    462 ignore = [".git/", "node_modules/", "**/__pycache__/"]
    463 
    464 [settings.log]
    465 # file = "~/.local/share/esync/esync.log"
    466 format = "text"
    467 `) + "\n"
    468 }
    469 ```
    470 
    471 **Step 4: Run tests to verify they pass**
    472 
    473 ```bash
    474 cd internal/config && go test -v
    475 ```
    476 Expected: all PASS
    477 
    478 **Step 5: Commit**
    479 
    480 ```bash
    481 git add internal/config/
    482 git commit -m "feat: add config package with TOML loading, defaults, and search path"
    483 ```
    484 
    485 ---
    486 
    487 ### Task 3: Syncer Package
    488 
    489 **Files:**
    490 - Create: `internal/syncer/syncer.go`
    491 - Create: `internal/syncer/syncer_test.go`
    492 
    493 **Step 1: Write failing tests for rsync command building**
    494 
    495 ```go
    496 // internal/syncer/syncer_test.go
    497 package syncer
    498 
    499 import (
    500 	"testing"
    501 
    502 	"github.com/eloualiche/esync/internal/config"
    503 )
    504 
    505 func TestBuildCommand_Local(t *testing.T) {
    506 	cfg := &config.Config{
    507 		Sync: config.SyncSection{
    508 			Local:  "/tmp/src",
    509 			Remote: "/tmp/dst",
    510 		},
    511 		Settings: config.Settings{
    512 			Rsync: config.RsyncSettings{
    513 				Archive:  true,
    514 				Compress: true,
    515 				Progress: true,
    516 				Ignore:   []string{".git/", "node_modules/"},
    517 			},
    518 		},
    519 	}
    520 
    521 	s := New(cfg)
    522 	cmd := s.BuildCommand()
    523 
    524 	if cmd[0] != "rsync" {
    525 		t.Errorf("expected rsync, got %s", cmd[0])
    526 	}
    527 	if !contains(cmd, "--archive") {
    528 		t.Error("expected --archive flag")
    529 	}
    530 	if !contains(cmd, "--compress") {
    531 		t.Error("expected --compress flag")
    532 	}
    533 	// Source should end with /
    534 	source := cmd[len(cmd)-2]
    535 	if source[len(source)-1] != '/' {
    536 		t.Errorf("source should end with /, got %s", source)
    537 	}
    538 }
    539 
    540 func TestBuildCommand_Remote(t *testing.T) {
    541 	cfg := &config.Config{
    542 		Sync: config.SyncSection{
    543 			Local:  "/tmp/src",
    544 			Remote: "user@host:/deploy",
    545 		},
    546 	}
    547 
    548 	s := New(cfg)
    549 	cmd := s.BuildCommand()
    550 
    551 	dest := cmd[len(cmd)-1]
    552 	if dest != "user@host:/deploy" {
    553 		t.Errorf("expected user@host:/deploy, got %s", dest)
    554 	}
    555 }
    556 
    557 func TestBuildCommand_SSHConfig(t *testing.T) {
    558 	cfg := &config.Config{
    559 		Sync: config.SyncSection{
    560 			Local:  "/tmp/src",
    561 			Remote: "/deploy",
    562 			SSH: &config.SSHConfig{
    563 				Host:         "example.com",
    564 				User:         "deploy",
    565 				Port:         2222,
    566 				IdentityFile: "~/.ssh/id_ed25519",
    567 			},
    568 		},
    569 	}
    570 
    571 	s := New(cfg)
    572 	cmd := s.BuildCommand()
    573 
    574 	dest := cmd[len(cmd)-1]
    575 	if dest != "deploy@example.com:/deploy" {
    576 		t.Errorf("expected deploy@example.com:/deploy, got %s", dest)
    577 	}
    578 	if !containsPrefix(cmd, "-e") {
    579 		t.Error("expected -e flag for SSH")
    580 	}
    581 }
    582 
    583 func TestBuildCommand_ExcludePatterns(t *testing.T) {
    584 	cfg := &config.Config{
    585 		Sync: config.SyncSection{
    586 			Local:  "/tmp/src",
    587 			Remote: "/tmp/dst",
    588 		},
    589 		Settings: config.Settings{
    590 			Ignore: []string{"*.log"},
    591 			Rsync: config.RsyncSettings{
    592 				Ignore: []string{".git/"},
    593 			},
    594 		},
    595 	}
    596 
    597 	s := New(cfg)
    598 	cmd := s.BuildCommand()
    599 
    600 	excludeCount := 0
    601 	for _, arg := range cmd {
    602 		if arg == "--exclude" {
    603 			excludeCount++
    604 		}
    605 	}
    606 	if excludeCount != 2 {
    607 		t.Errorf("expected 2 exclude flags, got %d", excludeCount)
    608 	}
    609 }
    610 
    611 func TestBuildCommand_ExtraArgs(t *testing.T) {
    612 	cfg := &config.Config{
    613 		Sync: config.SyncSection{
    614 			Local:  "/tmp/src",
    615 			Remote: "/tmp/dst",
    616 		},
    617 		Settings: config.Settings{
    618 			Rsync: config.RsyncSettings{
    619 				ExtraArgs: []string{"--delete", "--checksum"},
    620 			},
    621 		},
    622 	}
    623 
    624 	s := New(cfg)
    625 	cmd := s.BuildCommand()
    626 
    627 	if !contains(cmd, "--delete") {
    628 		t.Error("expected --delete from extra_args")
    629 	}
    630 	if !contains(cmd, "--checksum") {
    631 		t.Error("expected --checksum from extra_args")
    632 	}
    633 }
    634 
    635 func TestBuildCommand_DryRun(t *testing.T) {
    636 	cfg := &config.Config{
    637 		Sync: config.SyncSection{
    638 			Local:  "/tmp/src",
    639 			Remote: "/tmp/dst",
    640 		},
    641 	}
    642 
    643 	s := New(cfg)
    644 	s.DryRun = true
    645 	cmd := s.BuildCommand()
    646 
    647 	if !contains(cmd, "--dry-run") {
    648 		t.Error("expected --dry-run flag")
    649 	}
    650 }
    651 
    652 func TestBuildCommand_Backup(t *testing.T) {
    653 	cfg := &config.Config{
    654 		Sync: config.SyncSection{
    655 			Local:  "/tmp/src",
    656 			Remote: "/tmp/dst",
    657 		},
    658 		Settings: config.Settings{
    659 			Rsync: config.RsyncSettings{
    660 				Backup:    true,
    661 				BackupDir: ".backup",
    662 			},
    663 		},
    664 	}
    665 
    666 	s := New(cfg)
    667 	cmd := s.BuildCommand()
    668 
    669 	if !contains(cmd, "--backup") {
    670 		t.Error("expected --backup flag")
    671 	}
    672 	if !contains(cmd, "--backup-dir=.backup") {
    673 		t.Error("expected --backup-dir flag")
    674 	}
    675 }
    676 
    677 func contains(args []string, target string) bool {
    678 	for _, a := range args {
    679 		if a == target {
    680 			return true
    681 		}
    682 	}
    683 	return false
    684 }
    685 
    686 func containsPrefix(args []string, prefix string) bool {
    687 	for _, a := range args {
    688 		if len(a) >= len(prefix) && a[:len(prefix)] == prefix {
    689 			return true
    690 		}
    691 	}
    692 	return false
    693 }
    694 ```
    695 
    696 **Step 2: Run tests to verify they fail**
    697 
    698 ```bash
    699 go test ./internal/syncer/ -v
    700 ```
    701 
    702 **Step 3: Implement syncer package**
    703 
    704 ```go
    705 // internal/syncer/syncer.go
    706 package syncer
    707 
    708 import (
    709 	"fmt"
    710 	"os/exec"
    711 	"regexp"
    712 	"strconv"
    713 	"strings"
    714 	"time"
    715 
    716 	"github.com/eloualiche/esync/internal/config"
    717 )
    718 
    719 // Result holds the outcome of a sync operation.
    720 type Result struct {
    721 	Success      bool
    722 	FilesCount   int
    723 	BytesTotal   int64
    724 	Duration     time.Duration
    725 	Files        []string
    726 	ErrorMessage string
    727 }
    728 
    729 // Syncer builds and executes rsync commands.
    730 type Syncer struct {
    731 	cfg    *config.Config
    732 	DryRun bool
    733 }
    734 
    735 // New creates a new Syncer.
    736 func New(cfg *config.Config) *Syncer {
    737 	return &Syncer{cfg: cfg}
    738 }
    739 
    740 // BuildCommand constructs the rsync argument list.
    741 func (s *Syncer) BuildCommand() []string {
    742 	cmd := []string{"rsync", "--recursive", "--times", "--progress", "--copy-unsafe-links"}
    743 
    744 	rs := s.cfg.Settings.Rsync
    745 	if rs.Archive {
    746 		cmd = append(cmd, "--archive")
    747 	}
    748 	if rs.Compress {
    749 		cmd = append(cmd, "--compress")
    750 	}
    751 	if rs.Backup {
    752 		cmd = append(cmd, "--backup")
    753 		cmd = append(cmd, fmt.Sprintf("--backup-dir=%s", rs.BackupDir))
    754 	}
    755 	if s.DryRun {
    756 		cmd = append(cmd, "--dry-run")
    757 	}
    758 
    759 	// Exclude patterns
    760 	for _, pattern := range s.cfg.AllIgnorePatterns() {
    761 		clean := strings.Trim(pattern, "\"[]'")
    762 		if strings.HasPrefix(clean, "**/") {
    763 			clean = clean[3:]
    764 		}
    765 		cmd = append(cmd, "--exclude", clean)
    766 	}
    767 
    768 	// Extra args passthrough
    769 	cmd = append(cmd, rs.ExtraArgs...)
    770 
    771 	// SSH options
    772 	sshCmd := s.buildSSHCommand()
    773 	if sshCmd != "" {
    774 		cmd = append(cmd, "-e", sshCmd)
    775 	}
    776 
    777 	// Source (always ends with /)
    778 	source := s.cfg.Sync.Local
    779 	if !strings.HasSuffix(source, "/") {
    780 		source += "/"
    781 	}
    782 	cmd = append(cmd, source)
    783 
    784 	// Destination
    785 	cmd = append(cmd, s.buildDestination())
    786 
    787 	return cmd
    788 }
    789 
    790 // Run executes the rsync command and returns the result.
    791 func (s *Syncer) Run() (*Result, error) {
    792 	args := s.BuildCommand()
    793 	start := time.Now()
    794 
    795 	c := exec.Command(args[0], args[1:]...)
    796 	output, err := c.CombinedOutput()
    797 	duration := time.Since(start)
    798 
    799 	result := &Result{
    800 		Duration: duration,
    801 		Files:    extractFiles(string(output)),
    802 	}
    803 
    804 	if err != nil {
    805 		result.Success = false
    806 		result.ErrorMessage = strings.TrimSpace(string(output))
    807 		if result.ErrorMessage == "" {
    808 			result.ErrorMessage = err.Error()
    809 		}
    810 		return result, err
    811 	}
    812 
    813 	result.Success = true
    814 	result.FilesCount, result.BytesTotal = extractStats(string(output))
    815 	return result, nil
    816 }
    817 
    818 func (s *Syncer) buildSSHCommand() string {
    819 	ssh := s.cfg.Sync.SSH
    820 	if ssh == nil {
    821 		return ""
    822 	}
    823 	parts := []string{"ssh"}
    824 	if ssh.Port != 0 && ssh.Port != 22 {
    825 		parts = append(parts, fmt.Sprintf("-p %d", ssh.Port))
    826 	}
    827 	if ssh.IdentityFile != "" {
    828 		parts = append(parts, fmt.Sprintf("-i %s", ssh.IdentityFile))
    829 	}
    830 	// ControlMaster for SSH keepalive
    831 	parts = append(parts, "-o", "ControlMaster=auto")
    832 	parts = append(parts, "-o", "ControlPath=/tmp/esync-ssh-%r@%h:%p")
    833 	parts = append(parts, "-o", "ControlPersist=600")
    834 	if len(parts) == 1 {
    835 		return ""
    836 	}
    837 	return strings.Join(parts, " ")
    838 }
    839 
    840 func (s *Syncer) buildDestination() string {
    841 	ssh := s.cfg.Sync.SSH
    842 	if ssh != nil && ssh.Host != "" {
    843 		if ssh.User != "" {
    844 			return fmt.Sprintf("%s@%s:%s", ssh.User, ssh.Host, s.cfg.Sync.Remote)
    845 		}
    846 		return fmt.Sprintf("%s:%s", ssh.Host, s.cfg.Sync.Remote)
    847 	}
    848 	return s.cfg.Sync.Remote
    849 }
    850 
    851 func extractFiles(output string) []string {
    852 	var files []string
    853 	skip := regexp.MustCompile(`^(building|sending|sent|total|bytes|\s*$)`)
    854 	for _, line := range strings.Split(output, "\n") {
    855 		trimmed := strings.TrimSpace(line)
    856 		if trimmed == "" || skip.MatchString(trimmed) {
    857 			continue
    858 		}
    859 		parts := strings.Fields(trimmed)
    860 		if len(parts) > 0 && !strings.Contains(parts[0], "%") {
    861 			files = append(files, parts[0])
    862 		}
    863 	}
    864 	return files
    865 }
    866 
    867 func extractStats(output string) (int, int64) {
    868 	fileRe := regexp.MustCompile(`(\d+) files? to consider`)
    869 	bytesRe := regexp.MustCompile(`sent ([\d,]+) bytes\s+received ([\d,]+) bytes`)
    870 
    871 	var count int
    872 	var total int64
    873 
    874 	if m := fileRe.FindStringSubmatch(output); len(m) > 1 {
    875 		count, _ = strconv.Atoi(m[1])
    876 	}
    877 	if m := bytesRe.FindStringSubmatch(output); len(m) > 2 {
    878 		sent, _ := strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
    879 		recv, _ := strconv.ParseInt(strings.ReplaceAll(m[2], ",", ""), 10, 64)
    880 		total = sent + recv
    881 	}
    882 	return count, total
    883 }
    884 ```
    885 
    886 **Step 4: Run tests**
    887 
    888 ```bash
    889 go test ./internal/syncer/ -v
    890 ```
    891 Expected: all PASS
    892 
    893 **Step 5: Commit**
    894 
    895 ```bash
    896 git add internal/syncer/
    897 git commit -m "feat: add syncer package with rsync command builder and SSH support"
    898 ```
    899 
    900 ---
    901 
    902 ### Task 4: Watcher Package
    903 
    904 **Files:**
    905 - Create: `internal/watcher/watcher.go`
    906 - Create: `internal/watcher/watcher_test.go`
    907 
    908 **Step 1: Write failing tests for debouncer**
    909 
    910 ```go
    911 // internal/watcher/watcher_test.go
    912 package watcher
    913 
    914 import (
    915 	"sync/atomic"
    916 	"testing"
    917 	"time"
    918 )
    919 
    920 func TestDebouncerBatchesEvents(t *testing.T) {
    921 	var callCount atomic.Int32
    922 	callback := func() { callCount.Add(1) }
    923 
    924 	d := NewDebouncer(100*time.Millisecond, callback)
    925 	defer d.Stop()
    926 
    927 	// Fire 5 events rapidly
    928 	for i := 0; i < 5; i++ {
    929 		d.Trigger()
    930 		time.Sleep(10 * time.Millisecond)
    931 	}
    932 
    933 	// Wait for debounce window to expire
    934 	time.Sleep(200 * time.Millisecond)
    935 
    936 	if count := callCount.Load(); count != 1 {
    937 		t.Errorf("expected 1 callback, got %d", count)
    938 	}
    939 }
    940 
    941 func TestDebouncerSeparateEvents(t *testing.T) {
    942 	var callCount atomic.Int32
    943 	callback := func() { callCount.Add(1) }
    944 
    945 	d := NewDebouncer(50*time.Millisecond, callback)
    946 	defer d.Stop()
    947 
    948 	d.Trigger()
    949 	time.Sleep(100 * time.Millisecond) // Wait for first debounce
    950 
    951 	d.Trigger()
    952 	time.Sleep(100 * time.Millisecond) // Wait for second debounce
    953 
    954 	if count := callCount.Load(); count != 2 {
    955 		t.Errorf("expected 2 callbacks, got %d", count)
    956 	}
    957 }
    958 ```
    959 
    960 **Step 2: Run tests to verify they fail**
    961 
    962 ```bash
    963 go test ./internal/watcher/ -v
    964 ```
    965 
    966 **Step 3: Implement watcher package**
    967 
    968 ```go
    969 // internal/watcher/watcher.go
    970 package watcher
    971 
    972 import (
    973 	"log"
    974 	"path/filepath"
    975 	"sync"
    976 	"time"
    977 
    978 	"github.com/fsnotify/fsnotify"
    979 )
    980 
    981 // Debouncer batches rapid events into a single callback.
    982 type Debouncer struct {
    983 	interval time.Duration
    984 	callback func()
    985 	timer    *time.Timer
    986 	mu       sync.Mutex
    987 	stopped  bool
    988 }
    989 
    990 // NewDebouncer creates a debouncer with the given interval.
    991 func NewDebouncer(interval time.Duration, callback func()) *Debouncer {
    992 	return &Debouncer{
    993 		interval: interval,
    994 		callback: callback,
    995 	}
    996 }
    997 
    998 // Trigger resets the debounce timer.
    999 func (d *Debouncer) Trigger() {
   1000 	d.mu.Lock()
   1001 	defer d.mu.Unlock()
   1002 	if d.stopped {
   1003 		return
   1004 	}
   1005 	if d.timer != nil {
   1006 		d.timer.Stop()
   1007 	}
   1008 	d.timer = time.AfterFunc(d.interval, d.callback)
   1009 }
   1010 
   1011 // Stop cancels any pending callback.
   1012 func (d *Debouncer) Stop() {
   1013 	d.mu.Lock()
   1014 	defer d.mu.Unlock()
   1015 	d.stopped = true
   1016 	if d.timer != nil {
   1017 		d.timer.Stop()
   1018 	}
   1019 }
   1020 
   1021 // EventHandler is called when files change.
   1022 type EventHandler func()
   1023 
   1024 // Watcher monitors a directory for changes using fsnotify.
   1025 type Watcher struct {
   1026 	fsw       *fsnotify.Watcher
   1027 	debouncer *Debouncer
   1028 	path      string
   1029 	ignores   []string
   1030 	done      chan struct{}
   1031 }
   1032 
   1033 // New creates a file watcher for the given path.
   1034 func New(path string, debounceMs int, ignores []string, handler EventHandler) (*Watcher, error) {
   1035 	fsw, err := fsnotify.NewWatcher()
   1036 	if err != nil {
   1037 		return nil, err
   1038 	}
   1039 
   1040 	interval := time.Duration(debounceMs) * time.Millisecond
   1041 	if interval == 0 {
   1042 		interval = 500 * time.Millisecond
   1043 	}
   1044 
   1045 	w := &Watcher{
   1046 		fsw:       fsw,
   1047 		debouncer: NewDebouncer(interval, handler),
   1048 		path:      path,
   1049 		ignores:   ignores,
   1050 		done:      make(chan struct{}),
   1051 	}
   1052 
   1053 	return w, nil
   1054 }
   1055 
   1056 // Start begins watching for file changes.
   1057 func (w *Watcher) Start() error {
   1058 	if err := w.addRecursive(w.path); err != nil {
   1059 		return err
   1060 	}
   1061 
   1062 	go w.loop()
   1063 	return nil
   1064 }
   1065 
   1066 // Stop ends the watcher.
   1067 func (w *Watcher) Stop() {
   1068 	w.debouncer.Stop()
   1069 	w.fsw.Close()
   1070 	<-w.done
   1071 }
   1072 
   1073 // Paused tracks whether watching is paused.
   1074 var Paused bool
   1075 
   1076 func (w *Watcher) loop() {
   1077 	defer close(w.done)
   1078 	for {
   1079 		select {
   1080 		case event, ok := <-w.fsw.Events:
   1081 			if !ok {
   1082 				return
   1083 			}
   1084 			if Paused {
   1085 				continue
   1086 			}
   1087 			if w.shouldIgnore(event.Name) {
   1088 				continue
   1089 			}
   1090 			if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 {
   1091 				// If a directory was created, watch it too
   1092 				if event.Op&fsnotify.Create != 0 {
   1093 					w.addRecursive(event.Name)
   1094 				}
   1095 				w.debouncer.Trigger()
   1096 			}
   1097 		case err, ok := <-w.fsw.Errors:
   1098 			if !ok {
   1099 				return
   1100 			}
   1101 			log.Printf("watcher error: %v", err)
   1102 		}
   1103 	}
   1104 }
   1105 
   1106 func (w *Watcher) shouldIgnore(path string) bool {
   1107 	base := filepath.Base(path)
   1108 	for _, pattern := range w.ignores {
   1109 		if matched, _ := filepath.Match(pattern, base); matched {
   1110 			return true
   1111 		}
   1112 		if matched, _ := filepath.Match(pattern, path); matched {
   1113 			return true
   1114 		}
   1115 	}
   1116 	return false
   1117 }
   1118 
   1119 func (w *Watcher) addRecursive(path string) error {
   1120 	return filepath.Walk(path, func(p string, info interface{}, err error) error {
   1121 		if err != nil {
   1122 			return nil // skip errors
   1123 		}
   1124 		// Only add directories
   1125 		return w.fsw.Add(p)
   1126 	})
   1127 }
   1128 ```
   1129 
   1130 Note: `addRecursive` uses `filepath.Walk` which needs `os.FileInfo`, not `interface{}`. The actual implementation should use the correct signature. The executing agent will fix this during implementation.
   1131 
   1132 **Step 4: Run tests**
   1133 
   1134 ```bash
   1135 go test ./internal/watcher/ -v
   1136 ```
   1137 Expected: all PASS
   1138 
   1139 **Step 5: Commit**
   1140 
   1141 ```bash
   1142 git add internal/watcher/
   1143 git commit -m "feat: add watcher package with fsnotify and debouncing"
   1144 ```
   1145 
   1146 ---
   1147 
   1148 ### Task 5: Logger Package
   1149 
   1150 **Files:**
   1151 - Create: `internal/logger/logger.go`
   1152 - Create: `internal/logger/logger_test.go`
   1153 
   1154 **Step 1: Write failing tests**
   1155 
   1156 ```go
   1157 // internal/logger/logger_test.go
   1158 package logger
   1159 
   1160 import (
   1161 	"encoding/json"
   1162 	"os"
   1163 	"path/filepath"
   1164 	"strings"
   1165 	"testing"
   1166 )
   1167 
   1168 func TestJSONLogger(t *testing.T) {
   1169 	dir := t.TempDir()
   1170 	logPath := filepath.Join(dir, "test.log")
   1171 
   1172 	l, err := New(logPath, "json")
   1173 	if err != nil {
   1174 		t.Fatalf("unexpected error: %v", err)
   1175 	}
   1176 	defer l.Close()
   1177 
   1178 	l.Info("synced", map[string]interface{}{
   1179 		"file": "main.go",
   1180 		"size": 2150,
   1181 	})
   1182 
   1183 	data, _ := os.ReadFile(logPath)
   1184 	lines := strings.TrimSpace(string(data))
   1185 
   1186 	var entry map[string]interface{}
   1187 	if err := json.Unmarshal([]byte(lines), &entry); err != nil {
   1188 		t.Fatalf("invalid JSON: %v\nline: %s", err, lines)
   1189 	}
   1190 	if entry["level"] != "info" {
   1191 		t.Errorf("expected level=info, got %v", entry["level"])
   1192 	}
   1193 	if entry["event"] != "synced" {
   1194 		t.Errorf("expected event=synced, got %v", entry["event"])
   1195 	}
   1196 }
   1197 
   1198 func TestTextLogger(t *testing.T) {
   1199 	dir := t.TempDir()
   1200 	logPath := filepath.Join(dir, "test.log")
   1201 
   1202 	l, err := New(logPath, "text")
   1203 	if err != nil {
   1204 		t.Fatalf("unexpected error: %v", err)
   1205 	}
   1206 	defer l.Close()
   1207 
   1208 	l.Info("synced", map[string]interface{}{"file": "main.go"})
   1209 
   1210 	data, _ := os.ReadFile(logPath)
   1211 	line := string(data)
   1212 	if !strings.Contains(line, "INF") {
   1213 		t.Errorf("expected INF in text log, got: %s", line)
   1214 	}
   1215 	if !strings.Contains(line, "synced") {
   1216 		t.Errorf("expected 'synced' in text log, got: %s", line)
   1217 	}
   1218 }
   1219 ```
   1220 
   1221 **Step 2: Run tests to verify they fail**
   1222 
   1223 ```bash
   1224 go test ./internal/logger/ -v
   1225 ```
   1226 
   1227 **Step 3: Implement logger**
   1228 
   1229 ```go
   1230 // internal/logger/logger.go
   1231 package logger
   1232 
   1233 import (
   1234 	"encoding/json"
   1235 	"fmt"
   1236 	"os"
   1237 	"strings"
   1238 	"sync"
   1239 	"time"
   1240 )
   1241 
   1242 // Logger writes structured log entries to a file.
   1243 type Logger struct {
   1244 	file   *os.File
   1245 	format string // "json" or "text"
   1246 	mu     sync.Mutex
   1247 }
   1248 
   1249 // New creates a logger writing to the given path.
   1250 func New(path string, format string) (*Logger, error) {
   1251 	if format == "" {
   1252 		format = "text"
   1253 	}
   1254 	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
   1255 	if err != nil {
   1256 		return nil, err
   1257 	}
   1258 	return &Logger{file: f, format: format}, nil
   1259 }
   1260 
   1261 // Close closes the log file.
   1262 func (l *Logger) Close() {
   1263 	if l.file != nil {
   1264 		l.file.Close()
   1265 	}
   1266 }
   1267 
   1268 // Info logs an info-level entry.
   1269 func (l *Logger) Info(event string, fields map[string]interface{}) {
   1270 	l.log("info", event, fields)
   1271 }
   1272 
   1273 // Warn logs a warning-level entry.
   1274 func (l *Logger) Warn(event string, fields map[string]interface{}) {
   1275 	l.log("warn", event, fields)
   1276 }
   1277 
   1278 // Error logs an error-level entry.
   1279 func (l *Logger) Error(event string, fields map[string]interface{}) {
   1280 	l.log("error", event, fields)
   1281 }
   1282 
   1283 // Debug logs a debug-level entry.
   1284 func (l *Logger) Debug(event string, fields map[string]interface{}) {
   1285 	l.log("debug", event, fields)
   1286 }
   1287 
   1288 func (l *Logger) log(level, event string, fields map[string]interface{}) {
   1289 	l.mu.Lock()
   1290 	defer l.mu.Unlock()
   1291 
   1292 	now := time.Now().Format("15:04:05")
   1293 
   1294 	if l.format == "json" {
   1295 		entry := map[string]interface{}{
   1296 			"time":  now,
   1297 			"level": level,
   1298 			"event": event,
   1299 		}
   1300 		for k, v := range fields {
   1301 			entry[k] = v
   1302 		}
   1303 		data, _ := json.Marshal(entry)
   1304 		fmt.Fprintln(l.file, string(data))
   1305 	} else {
   1306 		tag := strings.ToUpper(level[:3])
   1307 		parts := []string{fmt.Sprintf("%s %s %s", now, tag, event)}
   1308 		for k, v := range fields {
   1309 			parts = append(parts, fmt.Sprintf("%s=%v", k, v))
   1310 		}
   1311 		fmt.Fprintln(l.file, strings.Join(parts, " "))
   1312 	}
   1313 }
   1314 ```
   1315 
   1316 **Step 4: Run tests**
   1317 
   1318 ```bash
   1319 go test ./internal/logger/ -v
   1320 ```
   1321 Expected: all PASS
   1322 
   1323 **Step 5: Commit**
   1324 
   1325 ```bash
   1326 git add internal/logger/
   1327 git commit -m "feat: add logger package with JSON and text output"
   1328 ```
   1329 
   1330 ---
   1331 
   1332 ### Task 6: TUI — Styles and Dashboard
   1333 
   1334 **Files:**
   1335 - Create: `internal/tui/styles.go`
   1336 - Create: `internal/tui/dashboard.go`
   1337 - Create: `internal/tui/app.go`
   1338 
   1339 **Step 1: Create Lipgloss styles**
   1340 
   1341 ```go
   1342 // internal/tui/styles.go
   1343 package tui
   1344 
   1345 import "github.com/charmbracelet/lipgloss"
   1346 
   1347 var (
   1348 	titleStyle = lipgloss.NewStyle().
   1349 		Bold(true).
   1350 		Foreground(lipgloss.Color("12")) // blue
   1351 
   1352 	statusSynced = lipgloss.NewStyle().
   1353 		Foreground(lipgloss.Color("10")) // green
   1354 
   1355 	statusSyncing = lipgloss.NewStyle().
   1356 		Foreground(lipgloss.Color("11")) // yellow
   1357 
   1358 	statusError = lipgloss.NewStyle().
   1359 		Foreground(lipgloss.Color("9")) // red
   1360 
   1361 	dimStyle = lipgloss.NewStyle().
   1362 		Foreground(lipgloss.Color("8")) // dim gray
   1363 
   1364 	sectionStyle = lipgloss.NewStyle().
   1365 		BorderStyle(lipgloss.NormalBorder()).
   1366 		BorderBottom(true).
   1367 		BorderForeground(lipgloss.Color("8"))
   1368 
   1369 	helpStyle = lipgloss.NewStyle().
   1370 		Foreground(lipgloss.Color("8"))
   1371 )
   1372 ```
   1373 
   1374 **Step 2: Create dashboard Bubbletea model**
   1375 
   1376 ```go
   1377 // internal/tui/dashboard.go
   1378 package tui
   1379 
   1380 import (
   1381 	"fmt"
   1382 	"strings"
   1383 	"time"
   1384 
   1385 	tea "github.com/charmbracelet/bubbletea"
   1386 )
   1387 
   1388 // SyncEvent represents a file sync event for display.
   1389 type SyncEvent struct {
   1390 	File     string
   1391 	Size     string
   1392 	Duration time.Duration
   1393 	Status   string // "synced", "syncing", "error"
   1394 	Time     time.Time
   1395 }
   1396 
   1397 // DashboardModel is the main TUI view.
   1398 type DashboardModel struct {
   1399 	local       string
   1400 	remote      string
   1401 	status      string // "watching", "syncing", "paused", "error"
   1402 	lastSync    time.Time
   1403 	events      []SyncEvent
   1404 	totalSynced int
   1405 	totalBytes  string
   1406 	totalErrors int
   1407 	width       int
   1408 	height      int
   1409 	filter      string
   1410 	filtering   bool
   1411 }
   1412 
   1413 // NewDashboard creates the dashboard model.
   1414 func NewDashboard(local, remote string) DashboardModel {
   1415 	return DashboardModel{
   1416 		local:  local,
   1417 		remote: remote,
   1418 		status: "watching",
   1419 		events: []SyncEvent{},
   1420 	}
   1421 }
   1422 
   1423 func (m DashboardModel) Init() tea.Cmd {
   1424 	return tickCmd()
   1425 }
   1426 
   1427 // tickMsg triggers periodic UI refresh.
   1428 type tickMsg time.Time
   1429 
   1430 func tickCmd() tea.Cmd {
   1431 	return tea.Tick(time.Second, func(t time.Time) tea.Msg {
   1432 		return tickMsg(t)
   1433 	})
   1434 }
   1435 
   1436 // SyncEventMsg delivers a sync event to the TUI.
   1437 type SyncEventMsg SyncEvent
   1438 
   1439 // SyncStatsMsg updates aggregate stats.
   1440 type SyncStatsMsg struct {
   1441 	TotalSynced int
   1442 	TotalBytes  string
   1443 	TotalErrors int
   1444 }
   1445 
   1446 func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   1447 	switch msg := msg.(type) {
   1448 	case tea.KeyMsg:
   1449 		if m.filtering {
   1450 			switch msg.String() {
   1451 			case "enter", "esc":
   1452 				m.filtering = false
   1453 				if msg.String() == "esc" {
   1454 					m.filter = ""
   1455 				}
   1456 				return m, nil
   1457 			case "backspace":
   1458 				if len(m.filter) > 0 {
   1459 					m.filter = m.filter[:len(m.filter)-1]
   1460 				}
   1461 				return m, nil
   1462 			default:
   1463 				if len(msg.String()) == 1 {
   1464 					m.filter += msg.String()
   1465 				}
   1466 				return m, nil
   1467 			}
   1468 		}
   1469 
   1470 		switch msg.String() {
   1471 		case "q", "ctrl+c":
   1472 			return m, tea.Quit
   1473 		case "p":
   1474 			if m.status == "paused" {
   1475 				m.status = "watching"
   1476 			} else if m.status == "watching" {
   1477 				m.status = "paused"
   1478 			}
   1479 			return m, nil
   1480 		case "/":
   1481 			m.filtering = true
   1482 			m.filter = ""
   1483 			return m, nil
   1484 		}
   1485 
   1486 	case tea.WindowSizeMsg:
   1487 		m.width = msg.Width
   1488 		m.height = msg.Height
   1489 
   1490 	case tickMsg:
   1491 		return m, tickCmd()
   1492 
   1493 	case SyncEventMsg:
   1494 		e := SyncEvent(msg)
   1495 		m.events = append([]SyncEvent{e}, m.events...)
   1496 		if len(m.events) > 100 {
   1497 			m.events = m.events[:100]
   1498 		}
   1499 		if e.Status == "synced" {
   1500 			m.lastSync = e.Time
   1501 		}
   1502 
   1503 	case SyncStatsMsg:
   1504 		m.totalSynced = msg.TotalSynced
   1505 		m.totalBytes = msg.TotalBytes
   1506 		m.totalErrors = msg.TotalErrors
   1507 	}
   1508 
   1509 	return m, nil
   1510 }
   1511 
   1512 func (m DashboardModel) View() string {
   1513 	var b strings.Builder
   1514 
   1515 	// Header
   1516 	title := titleStyle.Render(" esync ")
   1517 	separator := dimStyle.Render(strings.Repeat("─", max(0, m.width-8)))
   1518 	b.WriteString(title + separator + "\n")
   1519 	b.WriteString(fmt.Sprintf("  %s → %s\n", m.local, m.remote))
   1520 
   1521 	// Status
   1522 	var statusStr string
   1523 	switch m.status {
   1524 	case "watching":
   1525 		ago := ""
   1526 		if !m.lastSync.IsZero() {
   1527 			ago = fmt.Sprintf(" (synced %s ago)", time.Since(m.lastSync).Round(time.Second))
   1528 		}
   1529 		statusStr = statusSynced.Render("●") + " Watching" + dimStyle.Render(ago)
   1530 	case "syncing":
   1531 		statusStr = statusSyncing.Render("⟳") + " Syncing..."
   1532 	case "paused":
   1533 		statusStr = dimStyle.Render("⏸") + " Paused"
   1534 	case "error":
   1535 		statusStr = statusError.Render("✗") + " Error"
   1536 	}
   1537 	b.WriteString("  " + statusStr + "\n\n")
   1538 
   1539 	// Recent events
   1540 	b.WriteString("  " + dimStyle.Render("Recent "+strings.Repeat("─", max(0, m.width-12))) + "\n")
   1541 	filtered := m.filteredEvents()
   1542 	shown := min(10, len(filtered))
   1543 	for i := 0; i < shown; i++ {
   1544 		e := filtered[i]
   1545 		var icon string
   1546 		switch e.Status {
   1547 		case "synced":
   1548 			icon = statusSynced.Render("✓")
   1549 		case "syncing":
   1550 			icon = statusSyncing.Render("⟳")
   1551 		case "error":
   1552 			icon = statusError.Render("✗")
   1553 		}
   1554 		dur := ""
   1555 		if e.Duration > 0 {
   1556 			dur = dimStyle.Render(fmt.Sprintf("%.1fs", e.Duration.Seconds()))
   1557 		}
   1558 		b.WriteString(fmt.Sprintf("  %s %-30s %8s  %s\n", icon, e.File, e.Size, dur))
   1559 	}
   1560 	b.WriteString("\n")
   1561 
   1562 	// Stats
   1563 	b.WriteString("  " + dimStyle.Render("Stats "+strings.Repeat("─", max(0, m.width-10))) + "\n")
   1564 	stats := fmt.Sprintf("  %d synced │ %s total │ %d errors",
   1565 		m.totalSynced, m.totalBytes, m.totalErrors)
   1566 	b.WriteString(dimStyle.Render(stats) + "\n\n")
   1567 
   1568 	// Help bar
   1569 	help := "  q quit  p pause  r full resync  l logs  d dry-run  / filter"
   1570 	if m.filtering {
   1571 		help = fmt.Sprintf("  filter: %s█  (enter to apply, esc to cancel)", m.filter)
   1572 	}
   1573 	b.WriteString(helpStyle.Render(help) + "\n")
   1574 
   1575 	return b.String()
   1576 }
   1577 
   1578 func (m DashboardModel) filteredEvents() []SyncEvent {
   1579 	if m.filter == "" {
   1580 		return m.events
   1581 	}
   1582 	var filtered []SyncEvent
   1583 	for _, e := range m.events {
   1584 		if strings.Contains(strings.ToLower(e.File), strings.ToLower(m.filter)) {
   1585 			filtered = append(filtered, e)
   1586 		}
   1587 	}
   1588 	return filtered
   1589 }
   1590 
   1591 func max(a, b int) int {
   1592 	if a > b {
   1593 		return a
   1594 	}
   1595 	return b
   1596 }
   1597 
   1598 func min(a, b int) int {
   1599 	if a < b {
   1600 		return a
   1601 	}
   1602 	return b
   1603 }
   1604 ```
   1605 
   1606 **Step 3: Create log view model**
   1607 
   1608 ```go
   1609 // internal/tui/logview.go
   1610 package tui
   1611 
   1612 import (
   1613 	"fmt"
   1614 	"strings"
   1615 	"time"
   1616 
   1617 	tea "github.com/charmbracelet/bubbletea"
   1618 )
   1619 
   1620 // LogEntry is a single log line.
   1621 type LogEntry struct {
   1622 	Time    time.Time
   1623 	Level   string // "INF", "WRN", "ERR"
   1624 	Message string
   1625 }
   1626 
   1627 // LogViewModel shows scrollable logs.
   1628 type LogViewModel struct {
   1629 	entries   []LogEntry
   1630 	offset    int
   1631 	width     int
   1632 	height    int
   1633 	filter    string
   1634 	filtering bool
   1635 }
   1636 
   1637 // NewLogView creates an empty log view.
   1638 func NewLogView() LogViewModel {
   1639 	return LogViewModel{}
   1640 }
   1641 
   1642 func (m LogViewModel) Init() tea.Cmd { return nil }
   1643 
   1644 func (m LogViewModel) Update(msg tea.Msg) (LogViewModel, tea.Cmd) {
   1645 	switch msg := msg.(type) {
   1646 	case tea.KeyMsg:
   1647 		if m.filtering {
   1648 			switch msg.String() {
   1649 			case "enter", "esc":
   1650 				m.filtering = false
   1651 				if msg.String() == "esc" {
   1652 					m.filter = ""
   1653 				}
   1654 			case "backspace":
   1655 				if len(m.filter) > 0 {
   1656 					m.filter = m.filter[:len(m.filter)-1]
   1657 				}
   1658 			default:
   1659 				if len(msg.String()) == 1 {
   1660 					m.filter += msg.String()
   1661 				}
   1662 			}
   1663 			return m, nil
   1664 		}
   1665 
   1666 		switch msg.String() {
   1667 		case "up", "k":
   1668 			if m.offset > 0 {
   1669 				m.offset--
   1670 			}
   1671 		case "down", "j":
   1672 			m.offset++
   1673 		case "/":
   1674 			m.filtering = true
   1675 			m.filter = ""
   1676 		}
   1677 
   1678 	case tea.WindowSizeMsg:
   1679 		m.width = msg.Width
   1680 		m.height = msg.Height
   1681 	}
   1682 
   1683 	return m, nil
   1684 }
   1685 
   1686 func (m LogViewModel) View() string {
   1687 	var b strings.Builder
   1688 
   1689 	title := titleStyle.Render(" esync ─ logs ")
   1690 	separator := dimStyle.Render(strings.Repeat("─", max(0, m.width-16)))
   1691 	b.WriteString(title + separator + "\n")
   1692 
   1693 	filtered := m.filteredEntries()
   1694 	visible := m.height - 4 // header + help
   1695 	if visible < 1 {
   1696 		visible = 10
   1697 	}
   1698 
   1699 	start := m.offset
   1700 	if start > len(filtered)-visible {
   1701 		start = max(0, len(filtered)-visible)
   1702 	}
   1703 	end := min(start+visible, len(filtered))
   1704 
   1705 	for i := start; i < end; i++ {
   1706 		e := filtered[i]
   1707 		ts := dimStyle.Render(e.Time.Format("15:04:05"))
   1708 		var lvl string
   1709 		switch e.Level {
   1710 		case "INF":
   1711 			lvl = statusSynced.Render("INF")
   1712 		case "WRN":
   1713 			lvl = statusSyncing.Render("WRN")
   1714 		case "ERR":
   1715 			lvl = statusError.Render("ERR")
   1716 		default:
   1717 			lvl = dimStyle.Render(e.Level)
   1718 		}
   1719 		b.WriteString(fmt.Sprintf("  %s %s %s\n", ts, lvl, e.Message))
   1720 	}
   1721 
   1722 	b.WriteString("\n")
   1723 	help := "  ↑↓ scroll  / filter  l back  q quit"
   1724 	if m.filtering {
   1725 		help = fmt.Sprintf("  filter: %s█  (enter to apply, esc to cancel)", m.filter)
   1726 	}
   1727 	b.WriteString(helpStyle.Render(help) + "\n")
   1728 
   1729 	return b.String()
   1730 }
   1731 
   1732 func (m LogViewModel) filteredEntries() []LogEntry {
   1733 	if m.filter == "" {
   1734 		return m.entries
   1735 	}
   1736 	var out []LogEntry
   1737 	for _, e := range m.entries {
   1738 		if strings.Contains(strings.ToLower(e.Message), strings.ToLower(m.filter)) {
   1739 			out = append(out, e)
   1740 		}
   1741 	}
   1742 	return out
   1743 }
   1744 
   1745 // AddEntry adds a log entry (called from outside the TUI update loop via a Cmd).
   1746 func (m *LogViewModel) AddEntry(entry LogEntry) {
   1747 	m.entries = append(m.entries, entry)
   1748 }
   1749 ```
   1750 
   1751 **Step 4: Create app model (root TUI that switches between dashboard and log view)**
   1752 
   1753 ```go
   1754 // internal/tui/app.go
   1755 package tui
   1756 
   1757 import (
   1758 	tea "github.com/charmbracelet/bubbletea"
   1759 )
   1760 
   1761 type view int
   1762 
   1763 const (
   1764 	viewDashboard view = iota
   1765 	viewLogs
   1766 )
   1767 
   1768 // AppModel is the root Bubbletea model.
   1769 type AppModel struct {
   1770 	dashboard DashboardModel
   1771 	logView   LogViewModel
   1772 	current   view
   1773 	// Channels for external events
   1774 	syncEvents chan SyncEvent
   1775 	logEntries chan LogEntry
   1776 }
   1777 
   1778 // NewApp creates the root TUI model.
   1779 func NewApp(local, remote string) *AppModel {
   1780 	return &AppModel{
   1781 		dashboard:  NewDashboard(local, remote),
   1782 		logView:    NewLogView(),
   1783 		current:    viewDashboard,
   1784 		syncEvents: make(chan SyncEvent, 100),
   1785 		logEntries: make(chan LogEntry, 100),
   1786 	}
   1787 }
   1788 
   1789 // SyncEventChan returns the channel to send sync events to the TUI.
   1790 func (m *AppModel) SyncEventChan() chan<- SyncEvent {
   1791 	return m.syncEvents
   1792 }
   1793 
   1794 // LogEntryChan returns the channel to send log entries to the TUI.
   1795 func (m *AppModel) LogEntryChan() chan<- LogEntry {
   1796 	return m.logEntries
   1797 }
   1798 
   1799 func (m *AppModel) Init() tea.Cmd {
   1800 	return tea.Batch(
   1801 		m.dashboard.Init(),
   1802 		m.waitForSyncEvent(),
   1803 		m.waitForLogEntry(),
   1804 	)
   1805 }
   1806 
   1807 func (m *AppModel) waitForSyncEvent() tea.Cmd {
   1808 	return func() tea.Msg {
   1809 		e := <-m.syncEvents
   1810 		return SyncEventMsg(e)
   1811 	}
   1812 }
   1813 
   1814 func (m *AppModel) waitForLogEntry() tea.Cmd {
   1815 	return func() tea.Msg {
   1816 		return <-m.logEntries
   1817 	}
   1818 }
   1819 
   1820 func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   1821 	switch msg := msg.(type) {
   1822 	case tea.KeyMsg:
   1823 		switch msg.String() {
   1824 		case "l":
   1825 			if m.current == viewDashboard {
   1826 				m.current = viewLogs
   1827 			} else {
   1828 				m.current = viewDashboard
   1829 			}
   1830 			return m, nil
   1831 		case "q", "ctrl+c":
   1832 			return m, tea.Quit
   1833 		}
   1834 
   1835 	case SyncEventMsg:
   1836 		var cmd tea.Cmd
   1837 		var model tea.Model
   1838 		model, cmd = m.dashboard.Update(msg)
   1839 		m.dashboard = model.(DashboardModel)
   1840 		return m, tea.Batch(cmd, m.waitForSyncEvent())
   1841 
   1842 	case LogEntry:
   1843 		m.logView.AddEntry(msg)
   1844 		return m, m.waitForLogEntry()
   1845 	}
   1846 
   1847 	// Delegate to current view
   1848 	switch m.current {
   1849 	case viewDashboard:
   1850 		var cmd tea.Cmd
   1851 		var model tea.Model
   1852 		model, cmd = m.dashboard.Update(msg)
   1853 		m.dashboard = model.(DashboardModel)
   1854 		return m, cmd
   1855 	case viewLogs:
   1856 		var cmd tea.Cmd
   1857 		m.logView, cmd = m.logView.Update(msg)
   1858 		return m, cmd
   1859 	}
   1860 
   1861 	return m, nil
   1862 }
   1863 
   1864 func (m *AppModel) View() string {
   1865 	switch m.current {
   1866 	case viewLogs:
   1867 		return m.logView.View()
   1868 	default:
   1869 		return m.dashboard.View()
   1870 	}
   1871 }
   1872 ```
   1873 
   1874 **Step 5: Verify build**
   1875 
   1876 ```bash
   1877 go build ./...
   1878 ```
   1879 
   1880 **Step 6: Commit**
   1881 
   1882 ```bash
   1883 git add internal/tui/
   1884 git commit -m "feat: add TUI with dashboard, log view, and Lipgloss styles"
   1885 ```
   1886 
   1887 ---
   1888 
   1889 ### Task 7: CLI Commands — sync
   1890 
   1891 **Files:**
   1892 - Create: `cmd/sync.go`
   1893 - Modify: `cmd/root.go`
   1894 
   1895 **Step 1: Implement sync command**
   1896 
   1897 ```go
   1898 // cmd/sync.go
   1899 package cmd
   1900 
   1901 import (
   1902 	"fmt"
   1903 	"os"
   1904 	"time"
   1905 
   1906 	tea "github.com/charmbracelet/bubbletea"
   1907 	"github.com/spf13/cobra"
   1908 
   1909 	"github.com/eloualiche/esync/internal/config"
   1910 	"github.com/eloualiche/esync/internal/logger"
   1911 	"github.com/eloualiche/esync/internal/syncer"
   1912 	"github.com/eloualiche/esync/internal/tui"
   1913 	"github.com/eloualiche/esync/internal/watcher"
   1914 )
   1915 
   1916 var (
   1917 	localPath   string
   1918 	remotePath  string
   1919 	daemon      bool
   1920 	dryRun      bool
   1921 	initialSync bool
   1922 	verbose     bool
   1923 )
   1924 
   1925 var syncCmd = &cobra.Command{
   1926 	Use:   "sync",
   1927 	Short: "Start watching and syncing files",
   1928 	Long:  "Watch a local directory for changes and sync them to a remote destination using rsync.",
   1929 	RunE:  runSync,
   1930 }
   1931 
   1932 func init() {
   1933 	syncCmd.Flags().StringVarP(&localPath, "local", "l", "", "local path to sync from")
   1934 	syncCmd.Flags().StringVarP(&remotePath, "remote", "r", "", "remote path to sync to")
   1935 	syncCmd.Flags().BoolVar(&daemon, "daemon", false, "run without TUI, log to file")
   1936 	syncCmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would sync without executing")
   1937 	syncCmd.Flags().BoolVar(&initialSync, "initial-sync", false, "force full sync on startup")
   1938 	syncCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
   1939 
   1940 	rootCmd.AddCommand(syncCmd)
   1941 }
   1942 
   1943 func runSync(cmd *cobra.Command, args []string) error {
   1944 	cfg, err := loadOrBuildConfig()
   1945 	if err != nil {
   1946 		return err
   1947 	}
   1948 
   1949 	// CLI overrides
   1950 	if localPath != "" {
   1951 		cfg.Sync.Local = localPath
   1952 	}
   1953 	if remotePath != "" {
   1954 		cfg.Sync.Remote = remotePath
   1955 	}
   1956 	if initialSync {
   1957 		cfg.Settings.InitialSync = true
   1958 	}
   1959 
   1960 	if cfg.Sync.Local == "" || cfg.Sync.Remote == "" {
   1961 		return fmt.Errorf("both local and remote paths are required (use -l and -r, or a config file)")
   1962 	}
   1963 
   1964 	s := syncer.New(cfg)
   1965 	s.DryRun = dryRun
   1966 
   1967 	// Optional initial sync
   1968 	if cfg.Settings.InitialSync {
   1969 		fmt.Println("Running initial sync...")
   1970 		if result, err := s.Run(); err != nil {
   1971 			fmt.Fprintf(os.Stderr, "initial sync failed: %s\n", result.ErrorMessage)
   1972 		}
   1973 	}
   1974 
   1975 	if daemon {
   1976 		return runDaemon(cfg, s)
   1977 	}
   1978 	return runTUI(cfg, s)
   1979 }
   1980 
   1981 func runTUI(cfg *config.Config, s *syncer.Syncer) error {
   1982 	app := tui.NewApp(cfg.Sync.Local, cfg.Sync.Remote)
   1983 
   1984 	// Set up watcher
   1985 	handler := func() {
   1986 		result, err := s.Run()
   1987 		event := tui.SyncEvent{
   1988 			Time: time.Now(),
   1989 		}
   1990 		if err != nil {
   1991 			event.Status = "error"
   1992 			event.File = result.ErrorMessage
   1993 		} else {
   1994 			event.Status = "synced"
   1995 			event.Duration = result.Duration
   1996 			if len(result.Files) > 0 {
   1997 				event.File = result.Files[0]
   1998 			} else {
   1999 				event.File = "(no changes)"
   2000 			}
   2001 			event.Size = formatSize(result.BytesTotal)
   2002 		}
   2003 		app.SyncEventChan() <- event
   2004 	}
   2005 
   2006 	w, err := watcher.New(
   2007 		cfg.Sync.Local,
   2008 		cfg.Settings.WatcherDebounce,
   2009 		cfg.AllIgnorePatterns(),
   2010 		handler,
   2011 	)
   2012 	if err != nil {
   2013 		return fmt.Errorf("creating watcher: %w", err)
   2014 	}
   2015 
   2016 	if err := w.Start(); err != nil {
   2017 		return fmt.Errorf("starting watcher: %w", err)
   2018 	}
   2019 	defer w.Stop()
   2020 
   2021 	p := tea.NewProgram(app, tea.WithAltScreen())
   2022 	if _, err := p.Run(); err != nil {
   2023 		return err
   2024 	}
   2025 	return nil
   2026 }
   2027 
   2028 func runDaemon(cfg *config.Config, s *syncer.Syncer) error {
   2029 	logPath := cfg.Settings.Log.File
   2030 	if logPath == "" {
   2031 		logPath = "esync.log"
   2032 	}
   2033 	logFormat := cfg.Settings.Log.Format
   2034 
   2035 	l, err := logger.New(logPath, logFormat)
   2036 	if err != nil {
   2037 		return fmt.Errorf("creating logger: %w", err)
   2038 	}
   2039 	defer l.Close()
   2040 
   2041 	fmt.Printf("esync daemon started (PID %d)\n", os.Getpid())
   2042 	fmt.Printf("Watching: %s → %s\n", cfg.Sync.Local, cfg.Sync.Remote)
   2043 	fmt.Printf("Log: %s\n", logPath)
   2044 
   2045 	l.Info("started", map[string]interface{}{
   2046 		"local":  cfg.Sync.Local,
   2047 		"remote": cfg.Sync.Remote,
   2048 		"pid":    os.Getpid(),
   2049 	})
   2050 
   2051 	handler := func() {
   2052 		result, err := s.Run()
   2053 		if err != nil {
   2054 			l.Error("sync_failed", map[string]interface{}{
   2055 				"error": result.ErrorMessage,
   2056 			})
   2057 			fmt.Print("\a") // terminal bell on error
   2058 		} else {
   2059 			fields := map[string]interface{}{
   2060 				"duration_ms": result.Duration.Milliseconds(),
   2061 				"bytes":       result.BytesTotal,
   2062 			}
   2063 			if len(result.Files) > 0 {
   2064 				fields["file"] = result.Files[0]
   2065 			}
   2066 			l.Info("synced", fields)
   2067 		}
   2068 	}
   2069 
   2070 	w, err := watcher.New(
   2071 		cfg.Sync.Local,
   2072 		cfg.Settings.WatcherDebounce,
   2073 		cfg.AllIgnorePatterns(),
   2074 		handler,
   2075 	)
   2076 	if err != nil {
   2077 		return fmt.Errorf("creating watcher: %w", err)
   2078 	}
   2079 
   2080 	if err := w.Start(); err != nil {
   2081 		return fmt.Errorf("starting watcher: %w", err)
   2082 	}
   2083 	defer w.Stop()
   2084 
   2085 	// Block until interrupted
   2086 	select {}
   2087 }
   2088 
   2089 func loadOrBuildConfig() (*config.Config, error) {
   2090 	if cfgFile != "" {
   2091 		return config.Load(cfgFile)
   2092 	}
   2093 
   2094 	// Quick mode: local + remote provided directly
   2095 	if localPath != "" && remotePath != "" {
   2096 		return &config.Config{
   2097 			Sync: config.SyncSection{
   2098 				Local:  localPath,
   2099 				Remote: remotePath,
   2100 			},
   2101 			Settings: config.Settings{
   2102 				WatcherDebounce: 500,
   2103 				Rsync: config.RsyncSettings{
   2104 					Archive:  true,
   2105 					Compress: true,
   2106 					Progress: true,
   2107 				},
   2108 			},
   2109 		}, nil
   2110 	}
   2111 
   2112 	// Try to find config file
   2113 	path := config.FindConfigFile()
   2114 	if path == "" {
   2115 		return nil, fmt.Errorf("no config file found; use -c, create esync.toml, or pass -l and -r")
   2116 	}
   2117 	return config.Load(path)
   2118 }
   2119 
   2120 func formatSize(bytes int64) string {
   2121 	switch {
   2122 	case bytes < 1024:
   2123 		return fmt.Sprintf("%dB", bytes)
   2124 	case bytes < 1024*1024:
   2125 		return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
   2126 	case bytes < 1024*1024*1024:
   2127 		return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024))
   2128 	default:
   2129 		return fmt.Sprintf("%.2fGB", float64(bytes)/(1024*1024*1024))
   2130 	}
   2131 }
   2132 ```
   2133 
   2134 **Step 2: Verify build**
   2135 
   2136 ```bash
   2137 go build ./...
   2138 ```
   2139 
   2140 **Step 3: Manual test**
   2141 
   2142 ```bash
   2143 mkdir -p /tmp/esync-test-src /tmp/esync-test-dst
   2144 echo "hello" > /tmp/esync-test-src/test.txt
   2145 go run . sync -l /tmp/esync-test-src -r /tmp/esync-test-dst
   2146 # TUI should appear. Modify test.txt in another terminal. Press q to quit.
   2147 ```
   2148 
   2149 **Step 4: Commit**
   2150 
   2151 ```bash
   2152 git add cmd/sync.go
   2153 git commit -m "feat: add sync command with TUI and daemon modes"
   2154 ```
   2155 
   2156 ---
   2157 
   2158 ### Task 8: CLI Commands — init (smart)
   2159 
   2160 **Files:**
   2161 - Create: `cmd/init.go`
   2162 
   2163 **Step 1: Implement smart init**
   2164 
   2165 ```go
   2166 // cmd/init.go
   2167 package cmd
   2168 
   2169 import (
   2170 	"bufio"
   2171 	"fmt"
   2172 	"os"
   2173 	"path/filepath"
   2174 	"strings"
   2175 
   2176 	"github.com/spf13/cobra"
   2177 
   2178 	"github.com/eloualiche/esync/internal/config"
   2179 )
   2180 
   2181 var initRemote string
   2182 
   2183 var initCmd = &cobra.Command{
   2184 	Use:   "init",
   2185 	Short: "Generate esync.toml from current directory",
   2186 	Long:  "Create an esync.toml config file by inspecting the current directory, importing .gitignore patterns, and detecting common exclusions.",
   2187 	RunE:  runInit,
   2188 }
   2189 
   2190 func init() {
   2191 	initCmd.Flags().StringVarP(&initRemote, "remote", "r", "", "pre-fill remote destination")
   2192 	rootCmd.AddCommand(initCmd)
   2193 }
   2194 
   2195 func runInit(cmd *cobra.Command, args []string) error {
   2196 	outPath := "esync.toml"
   2197 	if cfgFile != "" {
   2198 		outPath = cfgFile
   2199 	}
   2200 
   2201 	// Check if file exists
   2202 	if _, err := os.Stat(outPath); err == nil {
   2203 		fmt.Printf("Config file %s already exists. Overwrite? [y/N] ", outPath)
   2204 		reader := bufio.NewReader(os.Stdin)
   2205 		answer, _ := reader.ReadString('\n')
   2206 		answer = strings.TrimSpace(strings.ToLower(answer))
   2207 		if answer != "y" && answer != "yes" {
   2208 			fmt.Println("Aborted.")
   2209 			return nil
   2210 		}
   2211 	}
   2212 
   2213 	// Start with default TOML
   2214 	content := config.DefaultTOML()
   2215 
   2216 	// Detect .gitignore
   2217 	gitignorePatterns := readGitignore()
   2218 	if len(gitignorePatterns) > 0 {
   2219 		fmt.Printf("Detected .gitignore — imported %d patterns\n", len(gitignorePatterns))
   2220 	}
   2221 
   2222 	// Detect common directories to exclude
   2223 	autoExclude := detectCommonDirs()
   2224 	if len(autoExclude) > 0 {
   2225 		fmt.Printf("Auto-excluding: %s\n", strings.Join(autoExclude, ", "))
   2226 	}
   2227 
   2228 	// Prompt for remote if not provided
   2229 	remote := initRemote
   2230 	if remote == "" {
   2231 		fmt.Print("Remote destination? (e.g. user@host:/path) ")
   2232 		reader := bufio.NewReader(os.Stdin)
   2233 		remote, _ = reader.ReadString('\n')
   2234 		remote = strings.TrimSpace(remote)
   2235 	}
   2236 	if remote != "" {
   2237 		content = strings.Replace(content, `remote = "./remote"`, fmt.Sprintf(`remote = "%s"`, remote), 1)
   2238 	}
   2239 
   2240 	// Merge extra ignore patterns into rsync ignore
   2241 	if len(gitignorePatterns) > 0 || len(autoExclude) > 0 {
   2242 		allExtra := append(gitignorePatterns, autoExclude...)
   2243 		// Build the ignore array string
   2244 		var quoted []string
   2245 		for _, p := range allExtra {
   2246 			quoted = append(quoted, fmt.Sprintf(`"%s"`, p))
   2247 		}
   2248 		extraLine := strings.Join(quoted, ", ")
   2249 		// Append to existing ignore array
   2250 		content = strings.Replace(content,
   2251 			`ignore = [".git/", "node_modules/", "**/__pycache__/"]`,
   2252 			fmt.Sprintf(`ignore = [".git/", "node_modules/", "**/__pycache__/", %s]`, extraLine),
   2253 			1,
   2254 		)
   2255 	}
   2256 
   2257 	if err := os.WriteFile(outPath, []byte(content), 0644); err != nil {
   2258 		return fmt.Errorf("writing config: %w", err)
   2259 	}
   2260 
   2261 	fmt.Printf("\nWritten: %s\n", outPath)
   2262 	fmt.Println("\nRun `esync check` for file preview, `esync edit` to adjust")
   2263 
   2264 	return nil
   2265 }
   2266 
   2267 func readGitignore() []string {
   2268 	f, err := os.Open(".gitignore")
   2269 	if err != nil {
   2270 		return nil
   2271 	}
   2272 	defer f.Close()
   2273 
   2274 	var patterns []string
   2275 	scanner := bufio.NewScanner(f)
   2276 	for scanner.Scan() {
   2277 		line := strings.TrimSpace(scanner.Text())
   2278 		if line == "" || strings.HasPrefix(line, "#") {
   2279 			continue
   2280 		}
   2281 		// Skip patterns we already have as defaults
   2282 		if line == ".git" || line == ".git/" || line == "node_modules" || line == "node_modules/" || line == "__pycache__" || line == "__pycache__/" {
   2283 			continue
   2284 		}
   2285 		patterns = append(patterns, line)
   2286 	}
   2287 	return patterns
   2288 }
   2289 
   2290 func detectCommonDirs() []string {
   2291 	common := []string{".git/", "node_modules/", "__pycache__/", "build/", ".venv/", "dist/", ".tox/", ".mypy_cache/"}
   2292 	var found []string
   2293 	for _, dir := range common {
   2294 		clean := strings.TrimSuffix(dir, "/")
   2295 		if info, err := os.Stat(clean); err == nil && info.IsDir() {
   2296 			// Skip ones already in default config
   2297 			if dir == ".git/" || dir == "node_modules/" || dir == "__pycache__/" {
   2298 				continue
   2299 			}
   2300 			found = append(found, dir)
   2301 		}
   2302 	}
   2303 	return found
   2304 }
   2305 ```
   2306 
   2307 **Step 2: Verify build and test manually**
   2308 
   2309 ```bash
   2310 go build ./...
   2311 cd /tmp && mkdir test-init && cd test-init
   2312 echo "*.pyc" > .gitignore
   2313 /path/to/esync init -r user@host:/deploy
   2314 cat esync.toml
   2315 ```
   2316 
   2317 **Step 3: Commit**
   2318 
   2319 ```bash
   2320 git add cmd/init.go
   2321 git commit -m "feat: add smart init command with .gitignore import"
   2322 ```
   2323 
   2324 ---
   2325 
   2326 ### Task 9: CLI Commands — check and edit
   2327 
   2328 **Files:**
   2329 - Create: `cmd/check.go`
   2330 - Create: `cmd/edit.go`
   2331 
   2332 **Step 1: Implement check command**
   2333 
   2334 ```go
   2335 // cmd/check.go
   2336 package cmd
   2337 
   2338 import (
   2339 	"fmt"
   2340 	"os"
   2341 	"path/filepath"
   2342 	"strings"
   2343 
   2344 	"github.com/charmbracelet/lipgloss"
   2345 	"github.com/spf13/cobra"
   2346 
   2347 	"github.com/eloualiche/esync/internal/config"
   2348 )
   2349 
   2350 var checkCmd = &cobra.Command{
   2351 	Use:   "check",
   2352 	Short: "Validate config and show file include/exclude preview",
   2353 	RunE:  runCheck,
   2354 }
   2355 
   2356 func init() {
   2357 	rootCmd.AddCommand(checkCmd)
   2358 }
   2359 
   2360 func runCheck(cmd *cobra.Command, args []string) error {
   2361 	cfg, err := loadConfig()
   2362 	if err != nil {
   2363 		return err
   2364 	}
   2365 	return printPreview(cfg)
   2366 }
   2367 
   2368 func loadConfig() (*config.Config, error) {
   2369 	path := cfgFile
   2370 	if path == "" {
   2371 		path = config.FindConfigFile()
   2372 	}
   2373 	if path == "" {
   2374 		return nil, fmt.Errorf("no config file found")
   2375 	}
   2376 	return config.Load(path)
   2377 }
   2378 
   2379 func printPreview(cfg *config.Config) error {
   2380 	green := lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
   2381 	yellow := lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
   2382 	dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
   2383 
   2384 	fmt.Println(green.Render(" esync ─ config preview"))
   2385 	fmt.Printf("  Local:  %s\n", cfg.Sync.Local)
   2386 	fmt.Printf("  Remote: %s\n\n", cfg.Sync.Remote)
   2387 
   2388 	ignores := cfg.AllIgnorePatterns()
   2389 
   2390 	var included []string
   2391 	var excluded []excludedFile
   2392 	var totalSize int64
   2393 
   2394 	localPath := cfg.Sync.Local
   2395 	filepath.Walk(localPath, func(path string, info os.FileInfo, err error) error {
   2396 		if err != nil {
   2397 			return nil
   2398 		}
   2399 		rel, _ := filepath.Rel(localPath, path)
   2400 		if rel == "." {
   2401 			return nil
   2402 		}
   2403 
   2404 		for _, pattern := range ignores {
   2405 			clean := strings.Trim(pattern, "\"[]'")
   2406 			if strings.HasPrefix(clean, "**/") {
   2407 				clean = clean[3:]
   2408 			}
   2409 			base := filepath.Base(rel)
   2410 			if matched, _ := filepath.Match(clean, base); matched {
   2411 				excluded = append(excluded, excludedFile{path: rel, rule: pattern})
   2412 				if info.IsDir() {
   2413 					return filepath.SkipDir
   2414 				}
   2415 				return nil
   2416 			}
   2417 			if matched, _ := filepath.Match(clean, rel); matched {
   2418 				excluded = append(excluded, excludedFile{path: rel, rule: pattern})
   2419 				if info.IsDir() {
   2420 					return filepath.SkipDir
   2421 				}
   2422 				return nil
   2423 			}
   2424 			// Directory pattern matching
   2425 			if strings.HasSuffix(clean, "/") && info.IsDir() {
   2426 				dirName := strings.TrimSuffix(clean, "/")
   2427 				if base == dirName {
   2428 					excluded = append(excluded, excludedFile{path: rel + "/", rule: pattern})
   2429 					return filepath.SkipDir
   2430 				}
   2431 			}
   2432 		}
   2433 
   2434 		if !info.IsDir() {
   2435 			included = append(included, rel)
   2436 			totalSize += info.Size()
   2437 		}
   2438 		return nil
   2439 	})
   2440 
   2441 	// Show included
   2442 	fmt.Println(green.Render("  Included (sample):"))
   2443 	shown := min(10, len(included))
   2444 	for i := 0; i < shown; i++ {
   2445 		fmt.Printf("  %s\n", included[i])
   2446 	}
   2447 	if len(included) > shown {
   2448 		fmt.Printf("  %s\n", dim.Render(fmt.Sprintf("... %d more files", len(included)-shown)))
   2449 	}
   2450 	fmt.Println()
   2451 
   2452 	// Show excluded
   2453 	fmt.Println(yellow.Render("  Excluded by rules:"))
   2454 	shown = min(10, len(excluded))
   2455 	for i := 0; i < shown; i++ {
   2456 		fmt.Printf("  %-30s %s\n", excluded[i].path, dim.Render("["+excluded[i].rule+"]"))
   2457 	}
   2458 	if len(excluded) > shown {
   2459 		fmt.Printf("  %s\n", dim.Render(fmt.Sprintf("... %d more", len(excluded)-shown)))
   2460 	}
   2461 	fmt.Println()
   2462 
   2463 	fmt.Printf("  %s\n", dim.Render(fmt.Sprintf("%d files included (%s) │ %d excluded",
   2464 		len(included), formatSize(totalSize), len(excluded))))
   2465 
   2466 	return nil
   2467 }
   2468 
   2469 type excludedFile struct {
   2470 	path string
   2471 	rule string
   2472 }
   2473 ```
   2474 
   2475 **Step 2: Implement edit command**
   2476 
   2477 ```go
   2478 // cmd/edit.go
   2479 package cmd
   2480 
   2481 import (
   2482 	"fmt"
   2483 	"os"
   2484 	"os/exec"
   2485 
   2486 	"github.com/spf13/cobra"
   2487 
   2488 	"github.com/eloualiche/esync/internal/config"
   2489 )
   2490 
   2491 var editCmd = &cobra.Command{
   2492 	Use:   "edit",
   2493 	Short: "Open config in $EDITOR, then show preview",
   2494 	RunE:  runEdit,
   2495 }
   2496 
   2497 func init() {
   2498 	rootCmd.AddCommand(editCmd)
   2499 }
   2500 
   2501 func runEdit(cmd *cobra.Command, args []string) error {
   2502 	path := cfgFile
   2503 	if path == "" {
   2504 		path = config.FindConfigFile()
   2505 	}
   2506 	if path == "" {
   2507 		return fmt.Errorf("no config file found; run `esync init` first")
   2508 	}
   2509 
   2510 	editor := os.Getenv("EDITOR")
   2511 	if editor == "" {
   2512 		editor = "vi"
   2513 	}
   2514 
   2515 	for {
   2516 		// Open editor
   2517 		c := exec.Command(editor, path)
   2518 		c.Stdin = os.Stdin
   2519 		c.Stdout = os.Stdout
   2520 		c.Stderr = os.Stderr
   2521 		if err := c.Run(); err != nil {
   2522 			return fmt.Errorf("editor failed: %w", err)
   2523 		}
   2524 
   2525 		// Validate and show preview
   2526 		cfg, err := config.Load(path)
   2527 		if err != nil {
   2528 			fmt.Printf("\nConfig error: %v\n", err)
   2529 			fmt.Print("Press enter to edit again, or q to cancel: ")
   2530 			var answer string
   2531 			fmt.Scanln(&answer)
   2532 			if answer == "q" {
   2533 				return nil
   2534 			}
   2535 			continue
   2536 		}
   2537 
   2538 		if err := printPreview(cfg); err != nil {
   2539 			return err
   2540 		}
   2541 
   2542 		fmt.Print("\nPress enter to accept, e to edit again, q to cancel: ")
   2543 		var answer string
   2544 		fmt.Scanln(&answer)
   2545 		switch answer {
   2546 		case "e":
   2547 			continue
   2548 		case "q":
   2549 			fmt.Println("Cancelled.")
   2550 			return nil
   2551 		default:
   2552 			fmt.Println("Config accepted.")
   2553 			return nil
   2554 		}
   2555 	}
   2556 }
   2557 ```
   2558 
   2559 **Step 3: Verify build**
   2560 
   2561 ```bash
   2562 go build ./...
   2563 ```
   2564 
   2565 **Step 4: Commit**
   2566 
   2567 ```bash
   2568 git add cmd/check.go cmd/edit.go
   2569 git commit -m "feat: add check and edit commands for config validation and preview"
   2570 ```
   2571 
   2572 ---
   2573 
   2574 ### Task 10: CLI Commands — status
   2575 
   2576 **Files:**
   2577 - Create: `cmd/status.go`
   2578 - Modify: `cmd/sync.go` (write PID file in daemon mode)
   2579 
   2580 **Step 1: Implement PID file in daemon mode**
   2581 
   2582 Add to `runDaemon` in `cmd/sync.go`:
   2583 ```go
   2584 // Write PID file
   2585 pidPath := filepath.Join(os.TempDir(), "esync.pid")
   2586 os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", os.Getpid())), 0644)
   2587 defer os.Remove(pidPath)
   2588 ```
   2589 
   2590 **Step 2: Implement status command**
   2591 
   2592 ```go
   2593 // cmd/status.go
   2594 package cmd
   2595 
   2596 import (
   2597 	"fmt"
   2598 	"os"
   2599 	"path/filepath"
   2600 	"strconv"
   2601 	"strings"
   2602 	"syscall"
   2603 
   2604 	"github.com/spf13/cobra"
   2605 )
   2606 
   2607 var statusCmd = &cobra.Command{
   2608 	Use:   "status",
   2609 	Short: "Check if esync daemon is running",
   2610 	RunE:  runStatus,
   2611 }
   2612 
   2613 func init() {
   2614 	rootCmd.AddCommand(statusCmd)
   2615 }
   2616 
   2617 func runStatus(cmd *cobra.Command, args []string) error {
   2618 	pidPath := filepath.Join(os.TempDir(), "esync.pid")
   2619 	data, err := os.ReadFile(pidPath)
   2620 	if err != nil {
   2621 		fmt.Println("No esync daemon running.")
   2622 		return nil
   2623 	}
   2624 
   2625 	pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
   2626 	if err != nil {
   2627 		fmt.Println("No esync daemon running (invalid PID file).")
   2628 		os.Remove(pidPath)
   2629 		return nil
   2630 	}
   2631 
   2632 	// Check if process is alive
   2633 	process, err := os.FindProcess(pid)
   2634 	if err != nil {
   2635 		fmt.Println("No esync daemon running.")
   2636 		os.Remove(pidPath)
   2637 		return nil
   2638 	}
   2639 
   2640 	// On Unix, FindProcess always succeeds. Send signal 0 to check.
   2641 	if err := process.Signal(syscall.Signal(0)); err != nil {
   2642 		fmt.Println("No esync daemon running (stale PID file).")
   2643 		os.Remove(pidPath)
   2644 		return nil
   2645 	}
   2646 
   2647 	fmt.Printf("esync daemon running (PID %d)\n", pid)
   2648 	return nil
   2649 }
   2650 ```
   2651 
   2652 **Step 3: Verify build**
   2653 
   2654 ```bash
   2655 go build ./...
   2656 ```
   2657 
   2658 **Step 4: Commit**
   2659 
   2660 ```bash
   2661 git add cmd/status.go cmd/sync.go
   2662 git commit -m "feat: add status command and PID file for daemon mode"
   2663 ```
   2664 
   2665 ---
   2666 
   2667 ### Task 11: Signal Handling and Graceful Shutdown
   2668 
   2669 **Files:**
   2670 - Modify: `cmd/sync.go`
   2671 
   2672 **Step 1: Add signal handling to daemon mode**
   2673 
   2674 Replace the `select {}` block at the end of `runDaemon` with:
   2675 
   2676 ```go
   2677 sigCh := make(chan os.Signal, 1)
   2678 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
   2679 <-sigCh
   2680 l.Info("stopping", nil)
   2681 fmt.Println("\nesync daemon stopped.")
   2682 ```
   2683 
   2684 And import `"os/signal"` and `"syscall"`.
   2685 
   2686 **Step 2: Test daemon start/stop**
   2687 
   2688 ```bash
   2689 go run . sync --daemon -l /tmp/esync-test-src -r /tmp/esync-test-dst &
   2690 go run . status
   2691 kill %1
   2692 ```
   2693 
   2694 **Step 3: Commit**
   2695 
   2696 ```bash
   2697 git add cmd/sync.go
   2698 git commit -m "feat: add graceful shutdown with signal handling"
   2699 ```
   2700 
   2701 ---
   2702 
   2703 ### Task 12: README
   2704 
   2705 **Files:**
   2706 - Modify: `readme.md`
   2707 
   2708 **Step 1: Write comprehensive README with TOML examples**
   2709 
   2710 Replace the entire README with documentation covering:
   2711 
   2712 - What esync does (1 paragraph)
   2713 - Installation (go install + binary download)
   2714 - Quick start (3 commands)
   2715 - Commands reference (sync, init, check, edit, status)
   2716 - Configuration reference with full annotated TOML example
   2717 - Config file search order
   2718 - SSH setup example
   2719 - Daemon mode usage
   2720 - TUI keyboard shortcuts
   2721 - Examples section with 5-6 common use cases
   2722 
   2723 Ensure thorough TOML examples per user request.
   2724 
   2725 **Step 2: Commit**
   2726 
   2727 ```bash
   2728 git add readme.md
   2729 git commit -m "docs: rewrite README for Go version with TOML examples"
   2730 ```
   2731 
   2732 ---
   2733 
   2734 ### Task 13: Integration Testing
   2735 
   2736 **Files:**
   2737 - Create: `integration_test.go`
   2738 
   2739 **Step 1: Write integration test for local sync**
   2740 
   2741 ```go
   2742 // integration_test.go
   2743 package main
   2744 
   2745 import (
   2746 	"os"
   2747 	"path/filepath"
   2748 	"testing"
   2749 	"time"
   2750 
   2751 	"github.com/eloualiche/esync/internal/config"
   2752 	"github.com/eloualiche/esync/internal/syncer"
   2753 	"github.com/eloualiche/esync/internal/watcher"
   2754 )
   2755 
   2756 func TestLocalSyncIntegration(t *testing.T) {
   2757 	src := t.TempDir()
   2758 	dst := t.TempDir()
   2759 
   2760 	// Create a test file
   2761 	os.WriteFile(filepath.Join(src, "hello.txt"), []byte("hello"), 0644)
   2762 
   2763 	cfg := &config.Config{
   2764 		Sync: config.SyncSection{
   2765 			Local:  src,
   2766 			Remote: dst,
   2767 		},
   2768 		Settings: config.Settings{
   2769 			WatcherDebounce: 100,
   2770 			Rsync: config.RsyncSettings{
   2771 				Archive:  true,
   2772 				Progress: true,
   2773 			},
   2774 		},
   2775 	}
   2776 
   2777 	s := syncer.New(cfg)
   2778 	result, err := s.Run()
   2779 	if err != nil {
   2780 		t.Fatalf("sync failed: %v", err)
   2781 	}
   2782 	if !result.Success {
   2783 		t.Fatalf("sync not successful: %s", result.ErrorMessage)
   2784 	}
   2785 
   2786 	// Verify file was synced
   2787 	data, err := os.ReadFile(filepath.Join(dst, "hello.txt"))
   2788 	if err != nil {
   2789 		t.Fatalf("synced file not found: %v", err)
   2790 	}
   2791 	if string(data) != "hello" {
   2792 		t.Errorf("expected 'hello', got %q", string(data))
   2793 	}
   2794 }
   2795 
   2796 func TestWatcherTriggersSync(t *testing.T) {
   2797 	src := t.TempDir()
   2798 	dst := t.TempDir()
   2799 
   2800 	cfg := &config.Config{
   2801 		Sync: config.SyncSection{
   2802 			Local:  src,
   2803 			Remote: dst,
   2804 		},
   2805 		Settings: config.Settings{
   2806 			WatcherDebounce: 100,
   2807 			Rsync: config.RsyncSettings{
   2808 				Archive:  true,
   2809 				Progress: true,
   2810 			},
   2811 		},
   2812 	}
   2813 
   2814 	s := syncer.New(cfg)
   2815 	synced := make(chan struct{}, 1)
   2816 
   2817 	handler := func() {
   2818 		s.Run()
   2819 		select {
   2820 		case synced <- struct{}{}:
   2821 		default:
   2822 		}
   2823 	}
   2824 
   2825 	w, err := watcher.New(src, 100, nil, handler)
   2826 	if err != nil {
   2827 		t.Fatalf("watcher creation failed: %v", err)
   2828 	}
   2829 	if err := w.Start(); err != nil {
   2830 		t.Fatalf("watcher start failed: %v", err)
   2831 	}
   2832 	defer w.Stop()
   2833 
   2834 	// Create a file to trigger sync
   2835 	time.Sleep(200 * time.Millisecond) // let watcher settle
   2836 	os.WriteFile(filepath.Join(src, "trigger.txt"), []byte("trigger"), 0644)
   2837 
   2838 	select {
   2839 	case <-synced:
   2840 		// Verify
   2841 		data, err := os.ReadFile(filepath.Join(dst, "trigger.txt"))
   2842 		if err != nil {
   2843 			t.Fatalf("file not synced: %v", err)
   2844 		}
   2845 		if string(data) != "trigger" {
   2846 			t.Errorf("expected 'trigger', got %q", string(data))
   2847 		}
   2848 	case <-time.After(5 * time.Second):
   2849 		t.Fatal("timeout waiting for sync")
   2850 	}
   2851 }
   2852 ```
   2853 
   2854 **Step 2: Run all tests**
   2855 
   2856 ```bash
   2857 go test ./... -v
   2858 ```
   2859 Expected: all PASS
   2860 
   2861 **Step 3: Commit**
   2862 
   2863 ```bash
   2864 git add integration_test.go
   2865 git commit -m "test: add integration tests for local sync and watcher"
   2866 ```
   2867 
   2868 ---
   2869 
   2870 ### Task 14: Example Config and Final Polish
   2871 
   2872 **Files:**
   2873 - Create: `esync.toml.example`
   2874 - Verify: `go build ./...` and `go vet ./...`
   2875 
   2876 **Step 1: Create example config**
   2877 
   2878 Write `esync.toml.example` with the full annotated schema from the design doc.
   2879 
   2880 **Step 2: Run linting and vet**
   2881 
   2882 ```bash
   2883 go vet ./...
   2884 go build -o esync .
   2885 ./esync --help
   2886 ./esync sync --help
   2887 ./esync init --help
   2888 ```
   2889 
   2890 **Step 3: Clean up go.sum**
   2891 
   2892 ```bash
   2893 go mod tidy
   2894 ```
   2895 
   2896 **Step 4: Final commit**
   2897 
   2898 ```bash
   2899 git add esync.toml.example go.mod go.sum
   2900 git commit -m "chore: add example config and tidy module"
   2901 ```
   2902 
   2903 ---
   2904 
   2905 ## Execution Order Summary
   2906 
   2907 | Task | Component | Depends On |
   2908 |------|-----------|------------|
   2909 | 1 | Project scaffolding | — |
   2910 | 2 | Config package | 1 |
   2911 | 3 | Syncer package | 2 |
   2912 | 4 | Watcher package | 1 |
   2913 | 5 | Logger package | 1 |
   2914 | 6 | TUI (styles, dashboard, log view) | 1 |
   2915 | 7 | CLI sync command | 2, 3, 4, 5, 6 |
   2916 | 8 | CLI init command | 2 |
   2917 | 9 | CLI check + edit commands | 2, 8 |
   2918 | 10 | CLI status command | 7 |
   2919 | 11 | Signal handling | 7 |
   2920 | 12 | README | all above |
   2921 | 13 | Integration tests | 3, 4 |
   2922 | 14 | Example config + polish | all above |
   2923 
   2924 **Parallelizable:** Tasks 2, 4, 5, 6 can run in parallel after Task 1. Tasks 8 and 13 can run in parallel with Task 7.