# Plan 4a — User-Facing Subcommands Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship `archduino theme`, `archduino new`, and `archduino doctor` subcommands plus a shared `internal/theming/` package that both the new `theme` subcommand and the existing `install` command use to write starship / zellij / yazi / lazygit / NvChad configs. **Architecture:** One new package (`theming`) owns per-theme embedded assets + the `Apply` function. Two subcommand packages (`newproject`, `doctor`) implement the remaining two subcommands. The `neovim` module exports a `WriteChadrc` helper that `theming.Apply` reuses to re-render chadrc when the theme changes. `tui.BuiltinThemes` retires; the wizard reads `theming.List()` instead. The `install` command calls `theming.Apply` after a successful `engine.Run` so the selected theme is applied on first run. **Tech Stack:** Go stdlib + `embed`, `gopkg.in/yaml.v3` (already a dep) for overlay round-trip, `huh` for the interactive theme picker, `text/template` for existing chadrc rendering. --- ## Reference: relevant existing surfaces ```go // internal/module/module.go — unchanged type Module interface { Meta() Meta; Detect(ctx) (State, error); Plan(ctx, State) (Plan, error); Apply(ctx, Plan, Reporter) error; Verify(ctx) error } // internal/platform — unchanged func WriteUserFile(path string, data []byte, mode os.FileMode) error type Runner interface { Run(ctx, name string, args ...string) error; Output(ctx, name string, args ...string) ([]byte, error) } // internal/config/config.go — has ThemeOverrides map[string]string already type Config struct { // ... existing fields ... ThemeOverrides map[string]string } const currentVersion = 1 // internal/modules/neovim/neovim.go — private helpers func (m Module) resolvedTheme() string // returns NvChad theme name func (m Module) renderChadrc() ([]byte, error) // renders chadrc.lua.tmpl // internal/tui/state.go — to be retired type Theme struct { ID, Name, NvChadTheme string } var BuiltinThemes = []Theme{ ... 5 entries ... } func ThemeByID(themes []Theme, id string) (Theme, bool) type WizardState struct { ThemeID string; ... } // cmd/archduino/main.go root := &cobra.Command{Use: "archduino"} root.AddCommand(newInstallCmd()) func newInstallCmd() *cobra.Command func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) ``` --- ## Task 1: `theming` package skeleton — Theme type, manifest discovery, gruvbox assets **Files:** - Create: `internal/theming/theming.go` - Create: `internal/theming/theming_test.go` - Create: `internal/theming/assets/gruvbox/theme.yaml` - Create: `internal/theming/assets/gruvbox/starship.toml` - Create: `internal/theming/assets/gruvbox/zellij.kdl` - Create: `internal/theming/assets/gruvbox/yazi.toml` - Create: `internal/theming/assets/gruvbox/lazygit.yml` - [ ] **Step 1: Create gruvbox assets** Extract contents from `/home/marvin/git-clones/ArduinoArchSetup/lib/themes/gruvbox.sh`'s `write_starship_config`, `write_zellij_config`, `write_lazygit_config`, `write_yazi_config` heredocs. Write each heredoc body verbatim to the corresponding asset file (drop the `TOML` / `KDL` / `YAML` end-tokens and the surrounding bash). `theme.yaml`: ```yaml display_name: "Gruvbox Dark" nvchad: "gruvbox" ``` - [ ] **Step 2: Write failing test** ```go package theming import ( "testing" ) func TestListDiscoversThemes(t *testing.T) { themes := List() if len(themes) == 0 { t.Fatal("no themes discovered") } for _, th := range themes { if th.ID == "" || th.DisplayName == "" || th.NvChadTheme == "" { t.Errorf("incomplete theme: %+v", th) } } } func TestGetKnownThemeReturnsMatch(t *testing.T) { got, ok := Get("gruvbox") if !ok { t.Fatal("gruvbox should exist") } if got.ID != "gruvbox" || got.DisplayName == "" || got.NvChadTheme != "gruvbox" { t.Fatalf("got %+v", got) } } func TestGetUnknownThemeReturnsFalse(t *testing.T) { if _, ok := Get("does-not-exist"); ok { t.Fatal("bogus id should return ok=false") } } func TestListIsSortedByID(t *testing.T) { themes := List() for i := 1; i < len(themes); i++ { if themes[i].ID < themes[i-1].ID { t.Fatalf("not sorted: %s before %s", themes[i-1].ID, themes[i].ID) } } } ``` - [ ] **Step 3: Run test, confirm fail** ``` go test ./internal/theming/... -v ``` Expected: `undefined: List` / `undefined: Get`. - [ ] **Step 4: Implement `theming.go` skeleton** ```go // Package theming applies a selected colorscheme across starship, zellij, // yazi, lazygit, and NvChad. Themes are discovered from embedded manifest // files under assets//theme.yaml at package init time. package theming import ( "embed" "fmt" "io/fs" "path/filepath" "sort" "strings" "gopkg.in/yaml.v3" ) //go:embed assets var assetsFS embed.FS // Theme describes a colorscheme discovered under assets//theme.yaml. type Theme struct { ID string DisplayName string NvChadTheme string } type manifest struct { DisplayName string `yaml:"display_name"` NvChad string `yaml:"nvchad"` } var discovered []Theme func init() { themes, err := discover() if err != nil { // Package is embed-only; discovery failure is a programmer error, // not runtime input. Panic so tests surface the mistake early. panic(fmt.Errorf("theming: discover themes: %w", err)) } discovered = themes } func discover() ([]Theme, error) { entries, err := fs.ReadDir(assetsFS, "assets") if err != nil { return nil, err } var out []Theme for _, e := range entries { if !e.IsDir() { continue } id := e.Name() m, err := readManifest(id) if err != nil { return nil, fmt.Errorf("theme %q: %w", id, err) } out = append(out, Theme{ID: id, DisplayName: m.DisplayName, NvChadTheme: m.NvChad}) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } func readManifest(id string) (manifest, error) { data, err := assetsFS.ReadFile(filepath.Join("assets", id, "theme.yaml")) if err != nil { return manifest{}, err } var m manifest if err := yaml.Unmarshal(data, &m); err != nil { return manifest{}, err } if m.DisplayName == "" { return manifest{}, fmt.Errorf("empty display_name") } if m.NvChad == "" { return manifest{}, fmt.Errorf("empty nvchad") } return m, nil } // List returns the themes sorted by ID. func List() []Theme { out := make([]Theme, len(discovered)) copy(out, discovered) return out } // Get returns the theme with the given ID, if present. func Get(id string) (Theme, bool) { for _, t := range discovered { if t.ID == id { return t, true } } return Theme{}, false } // stringsPrefix is a trivial helper reserved for Task 2's tool-config step. // It exists to keep the strings import live if Task 1's code doesn't use it. var _ = strings.Split ``` Delete the `_ = strings.Split` line if the build complains; Task 2 will reintroduce `strings` usage. - [ ] **Step 5: Run tests, confirm pass** ``` go test ./internal/theming/... -v go test ./... ``` - [ ] **Step 6: Commit** ```bash git add internal/theming/ git commit -m "feat(theming): add theme discovery with gruvbox assets" ``` --- ## Task 2: `theming.Apply` — write the four tool configs **Files:** - Modify: `internal/theming/theming.go` — add `Apply(cfg config.Config, themeID string) error`. - Modify: `internal/theming/theming_test.go` — add tests. - [ ] **Step 1: Write failing test** Append to `theming_test.go`: ```go import ( "os" "path/filepath" "github.com/MDietermann/ArduinoArchSetup/internal/config" ) func TestApplyWritesFourToolConfigs(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) if err := Apply(config.Config{}, "gruvbox"); err != nil { t.Fatal(err) } checks := []struct{ rel, wantPrefix string }{ {".config/starship.toml", "palette"}, {".config/zellij/config.kdl", "theme"}, {".config/yazi/theme.toml", ""}, {".config/lazygit/config.yml", "gui"}, } for _, c := range checks { data, err := os.ReadFile(filepath.Join(home, c.rel)) if err != nil { t.Errorf("%s missing: %v", c.rel, err); continue } if len(data) == 0 { t.Errorf("%s empty", c.rel) } if c.wantPrefix != "" && !strings.HasPrefix(strings.TrimSpace(string(data)), c.wantPrefix) { t.Errorf("%s: want prefix %q, got %q", c.rel, c.wantPrefix, string(data[:min(len(data),40)])) } } } func TestApplyUnknownThemeErrors(t *testing.T) { if err := Apply(config.Config{}, "does-not-exist"); err == nil { t.Fatal("want error on unknown theme") } } ``` (The `min` helper is stdlib in Go 1.21+; if the repo's go.mod is older, replace `min(len(data),40)` with a manual min.) - [ ] **Step 2: Run test, confirm fail** ``` go test ./internal/theming/... -v ``` Expected: `undefined: Apply`. - [ ] **Step 3: Implement `Apply` (initial version — writes tool configs only, no overlay, no chadrc)** Add to `theming.go`: ```go import ( "os" "path/filepath" "strings" "github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/platform" ) // toolConfig describes one tool's config file: its asset name under // assets// and its user-config destination relative to $HOME. type toolConfig struct { Asset string // e.g. "starship.toml" DestRel string // e.g. ".config/starship.toml" } var toolConfigs = []toolConfig{ {"starship.toml", ".config/starship.toml"}, {"zellij.kdl", ".config/zellij/config.kdl"}, {"yazi.toml", ".config/yazi/theme.toml"}, {"lazygit.yml", ".config/lazygit/config.yml"}, } // Apply writes each tool's theme config to the user's home directory. // Task 3 adds overlay-file update; Task 4 adds NvChad chadrc rewrite. func Apply(cfg config.Config, themeID string) error { th, ok := Get(themeID) if !ok { ids := make([]string, 0, len(discovered)) for _, t := range discovered { ids = append(ids, t.ID) } return fmt.Errorf("unknown theme %q; valid: %s", themeID, strings.Join(ids, ", ")) } home, err := os.UserHomeDir(); if err != nil { return err } for _, tc := range toolConfigs { data, err := assetsFS.ReadFile(filepath.Join("assets", th.ID, tc.Asset)) if err != nil { return fmt.Errorf("read asset %s: %w", tc.Asset, err) } dst := filepath.Join(home, tc.DestRel) if err := platform.WriteUserFile(dst, data, 0o644); err != nil { return fmt.Errorf("write %s: %w", tc.DestRel, err) } } _ = cfg // overlay update lands in Task 3 return nil } ``` Remove the `_ = strings.Split` line from Task 1 now that `strings` is used genuinely. - [ ] **Step 4: Run tests, confirm pass** ``` go test ./internal/theming/... -v go test ./... ``` - [ ] **Step 5: Commit** ```bash git add internal/theming/ git commit -m "feat(theming): apply writes four tool configs to $HOME" ``` --- ## Task 3: `theming.Apply` — overlay round-trip **Files:** - Modify: `internal/theming/theming.go` — extend `Apply`. - Modify: `internal/theming/theming_test.go` — add overlay tests. - [ ] **Step 1: Write failing test** Append: ```go func TestApplyUpdatesOverlayActive(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) if err := Apply(config.Config{}, "gruvbox"); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(home, ".config", "archduino", "overlay.yaml")) if err != nil { t.Fatalf("overlay missing: %v", err) } if !strings.Contains(string(data), "__active: gruvbox") { t.Fatalf("overlay missing active theme: %s", data) } } func TestApplyPreservesOtherOverlayKeys(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) // Seed overlay with a non-theme key. op := filepath.Join(home, ".config", "archduino", "overlay.yaml") os.MkdirAll(filepath.Dir(op), 0o755) os.WriteFile(op, []byte("version: 1\nextra_packages:\n - foo\n"), 0o644) if err := Apply(config.Config{}, "gruvbox"); err != nil { t.Fatal(err) } data, _ := os.ReadFile(op) if !strings.Contains(string(data), "- foo") { t.Fatalf("extra_packages lost: %s", data) } if !strings.Contains(string(data), "__active: gruvbox") { t.Fatalf("active theme missing after preserve: %s", data) } } ``` - [ ] **Step 2: Run tests, confirm fail** ``` go test ./internal/theming/... -run TestApplyUpdates -v go test ./internal/theming/... -run TestApplyPreserves -v ``` - [ ] **Step 3: Implement the overlay update** Add a helper and call it at the end of `Apply`: ```go func updateOverlayActive(themeID string) error { home, err := os.UserHomeDir(); if err != nil { return err } path := filepath.Join(home, ".config", "archduino", "overlay.yaml") // Load existing overlay into a generic map so we preserve sibling keys. var root map[string]any if data, err := os.ReadFile(path); err == nil { if err := yaml.Unmarshal(data, &root); err != nil { return fmt.Errorf("parse overlay: %w", err) } } if root == nil { root = map[string]any{} } // Ensure the version stamp is present so Load doesn't complain. if _, ok := root["version"]; !ok { root["version"] = 1 } // theme_overrides may be absent, nil, or a map under various yaml decode // shapes. Normalise to map[string]any. var overrides map[string]any if raw, ok := root["theme_overrides"]; ok { if m, ok := raw.(map[string]any); ok { overrides = m } } if overrides == nil { overrides = map[string]any{} } overrides["__active"] = themeID root["theme_overrides"] = overrides out, err := yaml.Marshal(root); if err != nil { return err } return platform.WriteUserFile(path, out, 0o644) } ``` In `Apply`, after the tool-config loop, add: ```go if err := updateOverlayActive(themeID); err != nil { return fmt.Errorf("update overlay: %w", err) } ``` - [ ] **Step 4: Run tests, confirm pass** ``` go test ./internal/theming/... -v go test ./... ``` - [ ] **Step 5: Commit** ```bash git add internal/theming/ git commit -m "feat(theming): apply updates overlay theme_overrides.__active" ``` --- ## Task 4: Extract neovim chadrc writer, wire into theming.Apply **Files:** - Modify: `internal/modules/neovim/neovim.go` — export `WriteChadrc`. - Modify: `internal/modules/neovim/neovim_test.go` — add targeted test. - Modify: `internal/theming/theming.go` — call `neovim.WriteChadrc` from `Apply`. - Modify: `internal/theming/theming_test.go` — add chadrc tests. - [ ] **Step 1: Add the exported helper in the neovim package** In `internal/modules/neovim/neovim.go`, add at file scope (near the other render helpers around line 91): ```go // WriteChadrc renders chadrc.lua for the given NvChad theme name and writes // it to ~/.config/nvim/lua/chadrc.lua. If the file does not already exist // and the surrounding nvim config dir does not exist, the function returns // nil without writing anything (the user hasn't installed neovim yet). // // Exported so the theming package can re-render chadrc on theme switches // without re-running the full Module pipeline. func WriteChadrc(nvChadThemeName string) error { home, err := os.UserHomeDir(); if err != nil { return err } cfg := filepath.Join(home, ".config", "nvim") chadrcPath := filepath.Join(cfg, "lua", "chadrc.lua") // If chadrc doesn't exist, only write when the parent dir exists. A // fresh system where nvim was never installed should be a no-op. if _, err := os.Stat(chadrcPath); err != nil { if _, err := os.Stat(cfg); err != nil { return nil } } tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl"); if err != nil { return err } tmpl, err := template.New("chadrc").Parse(string(tmplBytes)); if err != nil { return err } var buf bytes.Buffer if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: nvChadThemeName}); err != nil { return err } return platform.WriteUserFile(chadrcPath, buf.Bytes(), 0o644) } ``` (This duplicates a few lines from `Module.renderChadrc`; the duplication is acceptable since the renderer is small and the Module-scoped version carries the `Module`'s theme-ID fallback logic that doesn't belong in the standalone helper.) - [ ] **Step 2: Add a test for the new helper** In `internal/modules/neovim/neovim_test.go`, add: ```go func TestWriteChadrcWritesFile(t *testing.T) { home := t.TempDir(); t.Setenv("HOME", home) cfg := filepath.Join(home, ".config", "nvim") os.MkdirAll(cfg, 0o755) if err := WriteChadrc("dracula"); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), `theme = "dracula"`) { t.Fatalf("theme not applied: %s", data) } } func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) { home := t.TempDir(); t.Setenv("HOME", home) if err := WriteChadrc("gruvbox"); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")); !os.IsNotExist(err) { t.Fatal("chadrc should not be created on a bare system") } } ``` - [ ] **Step 3: Add theming integration** In `internal/theming/theming.go`, import the neovim package: ```go import ( // ... existing imports ... "github.com/MDietermann/ArduinoArchSetup/internal/modules/neovim" ) ``` After the overlay update in `Apply`: ```go if err := neovim.WriteChadrc(th.NvChadTheme); err != nil { return fmt.Errorf("update chadrc: %w", err) } // Clear NvChad's base46 theme cache so the new theme activates on next // nvim launch. Errors ignored — the cache may not exist. _ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46")) _ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes")) ``` Note: `home` must still be in scope at this point; if the existing code doesn't keep it, hoist `home, _ := os.UserHomeDir()` near the top of `Apply`. - [ ] **Step 4: Add theming tests** Append to `theming_test.go`: ```go func TestApplyRewritesChadrcIfPresent(t *testing.T) { home := t.TempDir(); t.Setenv("HOME", home) nvimCfg := filepath.Join(home, ".config", "nvim", "lua") os.MkdirAll(nvimCfg, 0o755) os.WriteFile(filepath.Join(nvimCfg, "chadrc.lua"), []byte(`M.base46 = { theme = "gruvbox" }`), 0o644) if err := Apply(config.Config{}, "dracula"); err != nil { t.Fatal(err) } data, _ := os.ReadFile(filepath.Join(nvimCfg, "chadrc.lua")) if !strings.Contains(string(data), `theme = "dracula"`) { t.Fatalf("chadrc not rewritten: %s", data) } } func TestApplyNoChadrcIsFine(t *testing.T) { home := t.TempDir(); t.Setenv("HOME", home) if err := Apply(config.Config{}, "gruvbox"); err != nil { t.Fatal(err) } } ``` - [ ] **Step 5: Run tests, confirm pass** ``` go test ./internal/theming/... -v go test ./internal/modules/neovim/... -v go test ./... ``` - [ ] **Step 6: Commit** ```bash git add internal/modules/neovim/ internal/theming/ git commit -m "feat(theming): integrate NvChad chadrc rewrite into Apply" ``` --- ## Task 5: Add the remaining four themes (catppuccin, tokyonight, nord, dracula) **Files:** - Create: `internal/theming/assets//{theme.yaml,starship.toml,zellij.kdl,yazi.toml,lazygit.yml}` for four themes. - [ ] **Step 1: Write failing test** Append to `theming_test.go`: ```go func TestAllFiveThemesPresent(t *testing.T) { want := []string{"catppuccin", "dracula", "gruvbox", "nord", "tokyonight"} themes := List() if len(themes) != len(want) { t.Fatalf("want %d themes, got %d", len(want), len(themes)) } for i, id := range want { if themes[i].ID != id { t.Fatalf("themes[%d].ID = %q, want %q", i, themes[i].ID, id) } } } func TestApplyEachThemeWritesStarship(t *testing.T) { for _, id := range []string{"catppuccin", "dracula", "nord", "tokyonight"} { id := id t.Run(id, func(t *testing.T) { home := t.TempDir(); t.Setenv("HOME", home) if err := Apply(config.Config{}, id); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(home, ".config", "starship.toml")) if err != nil { t.Fatal(err) } if len(data) == 0 { t.Fatal("empty starship.toml") } }) } } ``` - [ ] **Step 2: Confirm fail** ``` go test ./internal/theming/... -run TestAllFiveThemesPresent -v ``` Expected: `want 5 themes, got 1`. - [ ] **Step 3: Copy the four themes' assets** For each of `catppuccin`, `tokyonight`, `nord`, `dracula`: - Read `/home/marvin/git-clones/ArduinoArchSetup/lib/themes/.sh`. - Create directory `internal/theming/assets//`. - Extract heredoc bodies: `write_starship_config` → `starship.toml`, `write_zellij_config` → `zellij.kdl`, `write_yazi_config` → `yazi.toml`, `write_lazygit_config` → `lazygit.yml`. Drop the heredoc tokens; keep content verbatim. - Extract the top-of-file variables for the manifest: ```yaml display_name: "" nvchad: "" ``` The bash variables are `THEME_DISPLAY_NAME="..."` and `NVCHAD_THEME="..."` near lines 2-3 of each file. - [ ] **Step 4: Run tests, confirm pass** ``` go test ./internal/theming/... -v go test ./... ``` - [ ] **Step 5: Commit** ```bash git add internal/theming/assets/ git commit -m "feat(theming): add catppuccin, dracula, nord, tokyonight themes" ``` --- ## Task 6: Retire `tui.BuiltinThemes`; wizard reads `theming.List()` **Files:** - Modify: `internal/tui/state.go` — remove `BuiltinThemes`, `NvChadTheme`, `ThemeByID`; simplify `Theme`. - Modify: `internal/tui/state_test.go` — adjust or remove affected tests. - Modify: `internal/tui/wizard/wizard.go` — read from `theming.List()`. - Modify: `internal/modules/neovim/neovim.go` — drop the `tui.Theme`-based fallback in `resolvedTheme`; switch to `theming.Get`. - Modify: `cmd/archduino/main.go` — adjust any `tui.BuiltinThemes` references (Plan 3b added one for the pre-wizard `buildRegistry` call — replace with `""` or the first theme from `theming.List()`). - [ ] **Step 1: Grep current usages** ``` grep -rn "BuiltinThemes\|ThemeByID\|tui.Theme\b" --include="*.go" ``` Expected hits: `internal/tui/state.go`, `internal/tui/state_test.go`, `internal/tui/wizard/wizard.go`, `internal/modules/neovim/neovim.go`, `internal/modules/neovim/neovim_test.go`, `cmd/archduino/main.go`. Note every file before editing. - [ ] **Step 2: Simplify `tui.Theme`** In `internal/tui/state.go`: ```go // Theme is the wizard's display-only view of a colorscheme option. // Canonical theme metadata lives in internal/theming. type Theme struct { ID string Name string } ``` Remove `BuiltinThemes`, `ThemeByID`. In `NewWizardState`: ```go import "github.com/MDietermann/ArduinoArchSetup/internal/theming" func NewWizardState(cfg config.Config, reg *module.Registry) WizardState { // ... existing module-choice logic unchanged ... themes := theming.List() theme := "" if len(themes) > 0 { theme = themes[0].ID } if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" { theme = override } return WizardState{ ThemeID: theme, Modules: choices, Parallelism: 1, } } ``` - [ ] **Step 3: Rewrite affected tests** In `internal/tui/state_test.go`, replace any test referencing `BuiltinThemes` or `NvChadTheme` with equivalents against `theming.List()`. Specifically, the `TestBuiltinThemesCarryNvChadName` and `TestThemeByID*` tests (added in Plan 3b Task 2) should be **deleted** — the symbols they test no longer exist in `tui`. Their logic is already covered by `internal/theming`'s own tests (Task 1, Task 5). A `NewWizardState` test that expected `s.ThemeID == BuiltinThemes[0].ID` should be updated to `s.ThemeID == theming.List()[0].ID`. - [ ] **Step 4: Update wizard** In `internal/tui/wizard/wizard.go`: ```go import "github.com/MDietermann/ArduinoArchSetup/internal/theming" // ... in Run, replace the themeOptions construction (lines ~26-29): themes := theming.List() themeOptions := make([]huh.Option[string], 0, len(themes)) for _, t := range themes { themeOptions = append(themeOptions, huh.NewOption(t.DisplayName, t.ID)) } ``` Drop `import "github.com/MDietermann/ArduinoArchSetup/internal/tui"` if it's no longer used (the wizard still references `tui.WizardState` and `tui.ModuleChoice`, so keep it). - [ ] **Step 5: Update neovim module** In `internal/modules/neovim/neovim.go`, `resolvedTheme` currently takes `[]tui.Theme`. Simplify: ```go import "github.com/MDietermann/ArduinoArchSetup/internal/theming" func (m Module) resolvedTheme() string { if t, ok := theming.Get(m.themeID); ok { return t.NvChadTheme } if themes := theming.List(); len(themes) > 0 { return themes[0].NvChadTheme } return "gruvbox" } ``` Change `New` signature to drop the `themes []tui.Theme` parameter: ```go func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module { return Module{cfg: cfg, themeID: themeID, pm: pm, git: git} } ``` Remove the `themes []tui.Theme` field from `Module` struct. Update `internal/modules/neovim/neovim_test.go`: replace every `New(cfg, id, tui.BuiltinThemes, pm, git)` call with `New(cfg, id, pm, git)`. Drop the `tui` import if unused. - [ ] **Step 6: Update cmd/archduino/main.go** In `buildRegistry`, update the neovim registration: ```go neovim.New(cfg.Neovim, themeID, pm, git), ``` Replace any `tui.BuiltinThemes[0].ID` reference with `""` (neovim's `resolvedTheme` already falls back to `theming.List()[0].NvChadTheme`). - [ ] **Step 7: Run the full test suite** ``` go build ./... go test ./... ``` If tests fail due to leftover `BuiltinThemes` references the grep missed, fix them and rerun. - [ ] **Step 8: Commit** ```bash git add internal/tui/ internal/modules/neovim/ cmd/archduino/main.go git commit -m "refactor: retire tui.BuiltinThemes in favor of theming.List" ``` --- ## Task 7: Extend config with `Scaffolding.Boards` **Files:** - Modify: `internal/config/config.go` - Modify: `internal/config/defaults.yaml` - Modify: `internal/config/config_test.go` - [ ] **Step 1: Write failing test** Append to `config_test.go`: ```go func TestDefaultsCarryScaffoldingBoards(t *testing.T) { cfg, err := Load("/nonexistent/overlay.yaml") if err != nil { t.Fatal(err) } if len(cfg.Scaffolding.Boards) < 6 { t.Fatalf("want >= 6 boards, got %d", len(cfg.Scaffolding.Boards)) } want := map[string]string{ "uno": "arduino:avr:uno", "mega": "arduino:avr:mega", "nano": "arduino:avr:nano", "esp32": "esp32:esp32:esp32", "esp32s3": "esp32:esp32:esp32s3", "esp8266": "esp8266:esp8266:nodemcuv2", } got := map[string]string{} for _, b := range cfg.Scaffolding.Boards { got[b.Name] = b.FQBN } for name, fqbn := range want { if got[name] != fqbn { t.Errorf("board %q: got fqbn %q, want %q", name, got[name], fqbn) } } } ``` - [ ] **Step 2: Run test, confirm fail** ``` go test ./internal/config/... -run TestDefaultsCarryScaffoldingBoards -v ``` - [ ] **Step 3: Extend `config.go`** Add types above `Config`: ```go type Board struct { Name string `yaml:"name"` FQBN string `yaml:"fqbn"` Template string `yaml:"template"` } type Scaffolding struct { Boards []Board `yaml:"boards"` } ``` Add field to `Config`: ```go Scaffolding Scaffolding `yaml:"scaffolding"` ``` Extend `merge` with: ```go if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding } ``` - [ ] **Step 4: Extend `defaults.yaml`** Append: ```yaml scaffolding: boards: - {name: uno, fqbn: "arduino:avr:uno", template: uno} - {name: mega, fqbn: "arduino:avr:mega", template: mega} - {name: nano, fqbn: "arduino:avr:nano", template: nano} - {name: esp32, fqbn: "esp32:esp32:esp32", template: esp32} - {name: esp32s3, fqbn: "esp32:esp32:esp32s3", template: esp32s3} - {name: esp8266, fqbn: "esp8266:esp8266:nodemcuv2", template: esp8266} ``` - [ ] **Step 5: Run tests, confirm pass** ``` go test ./internal/config/... -v go test ./... ``` - [ ] **Step 6: Commit** ```bash git add internal/config/ git commit -m "feat(config): add Scaffolding.Boards typed input with 6 defaults" ``` --- ## Task 8: `newproject` subcommand package **Files:** - Create: `internal/subcommands/newproject/newproject.go` - Create: `internal/subcommands/newproject/newproject_test.go` - Create: `internal/subcommands/newproject/assets/default.ino` - Create: `internal/subcommands/newproject/assets/uno.ino` - Create: `internal/subcommands/newproject/assets/mega.ino` - Create: `internal/subcommands/newproject/assets/nano.ino` - Create: `internal/subcommands/newproject/assets/esp32.ino` - Create: `internal/subcommands/newproject/assets/esp32s3.ino` - Create: `internal/subcommands/newproject/assets/esp8266.ino` - [ ] **Step 1: Create .ino assets** Read `/home/marvin/git-clones/ArduinoArchSetup/lib/scaffold.sh` and extract the `cat >...` heredoc bodies for each per-board `.ino` template. If any board doesn't have its own heredoc in the bash file, copy the `default.ino` content for it. Keep each file's content verbatim. For `default.ino`, if the bash has no explicit default heredoc, use this minimal fallback: ``` void setup() { Serial.begin(115200); } void loop() { } ``` - [ ] **Step 2: Write failing test** ```go package newproject import ( "os" "path/filepath" "strings" "testing" "github.com/MDietermann/ArduinoArchSetup/internal/config" ) func sampleCfg() config.Config { return config.Config{ Scaffolding: config.Scaffolding{Boards: []config.Board{ {Name: "uno", FQBN: "arduino:avr:uno", Template: "uno"}, {Name: "xyz", FQBN: "fake:fqbn:xyz", Template: "does-not-exist"}, }}, } } func TestRunCreatesProject(t *testing.T) { dir := t.TempDir() t.Chdir(dir) if err := Run(sampleCfg(), "blinky", "uno"); err != nil { t.Fatal(err) } ino, err := os.ReadFile(filepath.Join("blinky", "blinky.ino")) if err != nil { t.Fatalf("blinky.ino missing: %v", err) } if len(ino) == 0 { t.Error("blinky.ino empty") } sketch, err := os.ReadFile(filepath.Join("blinky", "sketch.yaml")) if err != nil { t.Fatalf("sketch.yaml missing: %v", err) } if !strings.Contains(string(sketch), "arduino:avr:uno") { t.Errorf("sketch.yaml missing fqbn: %s", sketch) } if _, err := os.Stat(filepath.Join("blinky", ".clangd")); err != nil { t.Errorf(".clangd missing: %v", err) } } func TestRunRefusesExistingDir(t *testing.T) { dir := t.TempDir(); t.Chdir(dir) os.Mkdir("blinky", 0o755) if err := Run(sampleCfg(), "blinky", "uno"); err == nil { t.Fatal("want error on existing dir") } } func TestRunFallsBackToDefaultTemplate(t *testing.T) { dir := t.TempDir(); t.Chdir(dir) if err := Run(sampleCfg(), "thing", "xyz"); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join("thing", "thing.ino")); err != nil { t.Fatalf("thing.ino missing: %v", err) } } func TestRunUnknownBoardErrors(t *testing.T) { dir := t.TempDir(); t.Chdir(dir) err := Run(sampleCfg(), "thing", "bogus") if err == nil { t.Fatal("want error") } if !strings.Contains(err.Error(), "uno") { t.Errorf("error should list valid boards: %v", err) } } func TestRunRejectsSlashInProjectName(t *testing.T) { dir := t.TempDir(); t.Chdir(dir) if err := Run(sampleCfg(), "bad/name", "uno"); err == nil { t.Fatal("want error on slashes") } } ``` `t.Chdir` is Go 1.24+. If the repo's Go is older, replace with an explicit pushd pattern (record `os.Getwd`, `os.Chdir(dir)`, and a `t.Cleanup(func() { os.Chdir(old) })`). - [ ] **Step 3: Run tests, confirm fail** ``` go test ./internal/subcommands/newproject/... -v ``` Expected: `undefined: Run`. - [ ] **Step 4: Implement `newproject.go`** ```go // Package newproject scaffolds a new Arduino sketch directory. package newproject import ( "embed" "fmt" "os" "path/filepath" "strings" "github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/platform" ) //go:embed assets var assetsFS embed.FS const clangdContent = `CompileFlags: Add: - -xc++ - -std=c++17 ` // Run creates a new Arduino project directory. It errors if the project // directory already exists, if the board is unknown, or if the project name // contains a path separator. func Run(cfg config.Config, project, board string) error { if project == "" { return fmt.Errorf("project name required") } if strings.ContainsAny(project, "/\\") || strings.HasPrefix(project, ".") { return fmt.Errorf("invalid project name %q", project) } var b *config.Board var valid []string for i := range cfg.Scaffolding.Boards { bp := &cfg.Scaffolding.Boards[i] valid = append(valid, bp.Name) if bp.Name == board { b = bp } } if b == nil { return fmt.Errorf("unknown board %q; valid: %s", board, strings.Join(valid, ", ")) } if _, err := os.Stat(project); err == nil { return fmt.Errorf("project directory %q already exists", project) } if err := os.MkdirAll(project, 0o755); err != nil { return err } // Pick .ino template: try