diff --git a/docs/superpowers/plans/2026-04-14-plan4a-subcommands.md b/docs/superpowers/plans/2026-04-14-plan4a-subcommands.md new file mode 100644 index 0000000..e858f63 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-plan4a-subcommands.md @@ -0,0 +1,1514 @@ +# 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