Files
Archduino/docs/superpowers/plans/2026-04-14-plan4a-subcommands.md

50 KiB

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

// 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:

display_name: "Gruvbox Dark"
nvchad: "gruvbox"
  • Step 2: Write failing test
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
// Package theming applies a selected colorscheme across starship, zellij,
// yazi, lazygit, and NvChad. Themes are discovered from embedded manifest
// files under assets/<id>/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/<id>/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
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:

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:

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/<theme>/ 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
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:

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:

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:

    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
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):

// 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:

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:

import (
    // ... existing imports ...
    "github.com/MDietermann/ArduinoArchSetup/internal/modules/neovim"
)

After the overlay update in Apply:

    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:

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
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>/{theme.yaml,starship.toml,zellij.kdl,yazi.toml,lazygit.yml} for four themes.

  • Step 1: Write failing test

Append to theming_test.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/<theme>.sh.

  • Create directory internal/theming/assets/<theme>/.

  • 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:

    display_name: "<THEME_DISPLAY_NAME from bash>"
    nvchad: "<NVCHAD_THEME from bash>"
    

    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
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:

// 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:

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:

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:

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:

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:

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
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:

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:

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:

    Scaffolding     Scaffolding        `yaml:"scaffolding"`

Extend merge with:

    if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding }
  • Step 4: Extend defaults.yaml

Append:


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
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
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
// 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 <template>.ino, else default.ino.
    inoData, err := assetsFS.ReadFile(filepath.Join("assets", b.Template+".ino"))
    if err != nil {
        inoData, err = assetsFS.ReadFile("assets/default.ino")
        if err != nil { return err }
    }
    if err := platform.WriteUserFile(filepath.Join(project, project+".ino"), inoData, 0o644); err != nil {
        return err
    }

    sketch := fmt.Sprintf("default_fqbn: %s\ndefault_port: auto\n", b.FQBN)
    if err := platform.WriteUserFile(filepath.Join(project, "sketch.yaml"), []byte(sketch), 0o644); err != nil {
        return err
    }
    if err := platform.WriteUserFile(filepath.Join(project, ".clangd"), []byte(clangdContent), 0o644); err != nil {
        return err
    }
    return nil
}
  • Step 5: Run tests, confirm pass
go test ./internal/subcommands/newproject/... -v
go test ./...
  • Step 6: Commit
git add internal/subcommands/newproject/
git commit -m "feat(newproject): add archduino new project scaffolder"

Task 9: doctor subcommand package

Files:

  • Create: internal/subcommands/doctor/doctor.go

  • Create: internal/subcommands/doctor/doctor_test.go

  • Step 1: Write failing test

package doctor

import (
    "bytes"
    "context"
    "errors"
    "strings"
    "testing"

    "github.com/MDietermann/ArduinoArchSetup/internal/config"
    "github.com/MDietermann/ArduinoArchSetup/internal/module"
)

// convergedModule returns an empty Plan — always converged.
type convergedModule struct{ id string }
func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} }
func (c convergedModule) Detect(context.Context) (module.State, error) { return nil, nil }
func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) { return module.Plan{}, nil }
func (c convergedModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (c convergedModule) Verify(context.Context) error { return nil }

type pendingModule struct{ id string }
func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} }
func (p pendingModule) Detect(context.Context) (module.State, error) { return nil, nil }
func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) {
    return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil
}
func (p pendingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (p pendingModule) Verify(context.Context) error { return nil }

type detectFailModule struct{ id string }
func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} }
func (d detectFailModule) Detect(context.Context) (module.State, error) { return nil, errors.New("boom") }
func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) { return module.Plan{}, nil }
func (d detectFailModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (d detectFailModule) Verify(context.Context) error { return nil }

// notFoundRunner simulates "command not found" for version checks.
type notFoundRunner struct{}
func (notFoundRunner) Run(context.Context, string, ...string) error { return errors.New("not found") }
func (notFoundRunner) Output(context.Context, string, ...string) ([]byte, error) { return nil, errors.New("not found") }

// fixedRunner returns a preset version string for a matching command.
type fixedRunner struct {
    arduinoOut []byte
    arduinoErr error
    wokwiOut   []byte
    wokwiErr   error
}
func (f fixedRunner) Run(context.Context, string, ...string) error { return nil }
func (f fixedRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
    switch name {
    case "arduino-cli":
        return f.arduinoOut, f.arduinoErr
    case "wokwi-cli":
        return f.wokwiOut, f.wokwiErr
    }
    return nil, errors.New("no match")
}

func TestDoctorAllConvergedExitsZero(t *testing.T) {
    reg := module.NewRegistry()
    _ = reg.Register(convergedModule{id: "foo"})
    var buf bytes.Buffer
    code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
    if code != 0 { t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String()) }
    if !strings.Contains(buf.String(), "[✓] foo") {
        t.Errorf("missing converged mark: %s", buf.String())
    }
}

func TestDoctorDivergedExitsOne(t *testing.T) {
    reg := module.NewRegistry()
    _ = reg.Register(pendingModule{id: "bar"})
    var buf bytes.Buffer
    code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
    if code != 1 { t.Fatalf("exit %d, want 1", code) }
    if !strings.Contains(buf.String(), "[✗] bar") {
        t.Errorf("missing diverged mark: %s", buf.String())
    }
}

func TestDoctorDetectErrorIsReported(t *testing.T) {
    reg := module.NewRegistry()
    _ = reg.Register(detectFailModule{id: "baz"})
    var buf bytes.Buffer
    code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
    if code != 1 { t.Fatalf("exit %d, want 1", code) }
    if !strings.Contains(buf.String(), "[!] baz") {
        t.Errorf("missing error mark: %s", buf.String())
    }
}

func TestDoctorArduinoCLIDriftIsReported(t *testing.T) {
    reg := module.NewRegistry()
    _ = reg.Register(convergedModule{id: "foo"})
    cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
    runner := fixedRunner{arduinoOut: []byte(`{"VersionString":"0.36.0"}`)}
    var buf bytes.Buffer
    code := Run(context.Background(), cfg, reg, runner, &buf)
    if code != 1 { t.Fatalf("exit %d, want 1 (drift)", code) }
    if !strings.Contains(buf.String(), "drift") {
        t.Errorf("missing drift report: %s", buf.String())
    }
}

func TestDoctorMissingBinariesSkipVersionCheck(t *testing.T) {
    reg := module.NewRegistry()
    _ = reg.Register(convergedModule{id: "foo"})
    cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
    var buf bytes.Buffer
    code := Run(context.Background(), cfg, reg, notFoundRunner{}, &buf)
    if code != 0 { t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String()) }
}
  • Step 2: Run tests, confirm fail
go test ./internal/subcommands/doctor/... -v

Expected: undefined: Run.

  • Step 3: Implement doctor.go
// Package doctor reports module convergence and pinned-tool version drift.
package doctor

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "regexp"
    "strings"

    "github.com/MDietermann/ArduinoArchSetup/internal/config"
    "github.com/MDietermann/ArduinoArchSetup/internal/module"
    "github.com/MDietermann/ArduinoArchSetup/internal/platform"
)

// Run inspects every module in the registry plus pinned tool versions and
// writes a human-readable report to w. Returns 0 when everything is
// converged with no drift; 1 otherwise. Never panics on module errors.
func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) int {
    var converged, pending, errs, drift int

    for _, m := range reg.All() {
        id := m.Meta().ID
        st, err := m.Detect(ctx)
        if err != nil {
            fmt.Fprintf(w, "[!] %s — detect failed: %v\n", id, err)
            errs++
            continue
        }
        pl, err := m.Plan(ctx, st)
        if err != nil {
            fmt.Fprintf(w, "[!] %s — plan failed: %v\n", id, err)
            errs++
            continue
        }
        if pl.IsEmpty() {
            fmt.Fprintf(w, "[✓] %s\n", id)
            converged++
        } else {
            fmt.Fprintf(w, "[✗] %s (%d actions pending)\n", id, len(pl.Actions))
            pending++
        }
    }

    drift += checkArduinoCLI(ctx, runner, cfg.ArduinoCLI.Version, w)
    drift += checkWokwiCLI(ctx, runner, cfg.Renode.WokwiVersion, w)

    fmt.Fprintf(w, "\n%d converged, %d need-install, %d drift, %d errors\n", converged, pending, drift, errs)

    if pending > 0 || drift > 0 || errs > 0 { return 1 }
    return 0
}

type arduinoCLIVersion struct {
    VersionString string `json:"VersionString"`
}

func checkArduinoCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
    if want == "" { return 0 }
    out, err := runner.Output(ctx, "arduino-cli", "version", "--format", "json")
    if err != nil { return 0 } // not installed → silent skip
    var v arduinoCLIVersion
    if err := json.Unmarshal(out, &v); err != nil || v.VersionString == "" {
        fmt.Fprintf(w, "[!] arduino-cli — version parse failed\n")
        return 1
    }
    if v.VersionString != want {
        fmt.Fprintf(w, "[!] arduino-cli drift: got %s, want %s\n", v.VersionString, want)
        return 1
    }
    fmt.Fprintf(w, "[✓] arduino-cli %s\n", v.VersionString)
    return 0
}

var wokwiVersionRE = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)

func checkWokwiCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
    if want == "" { return 0 }
    out, err := runner.Output(ctx, "wokwi-cli", "--version")
    if err != nil { return 0 }
    m := wokwiVersionRE.FindStringSubmatch(strings.TrimSpace(string(out)))
    if len(m) < 2 {
        fmt.Fprintf(w, "[!] wokwi-cli — version parse failed\n")
        return 1
    }
    got := m[1]
    if got != want {
        fmt.Fprintf(w, "[!] wokwi-cli drift: got %s, want %s\n", got, want)
        return 1
    }
    fmt.Fprintf(w, "[✓] wokwi-cli %s\n", got)
    return 0
}
  • Step 4: Run tests, confirm pass
go test ./internal/subcommands/doctor/... -v
go test ./...
  • Step 5: Commit
git add internal/subcommands/doctor/
git commit -m "feat(doctor): add archduino doctor subcommand"

Task 10: Wire all three subcommands into cobra + integrate theming into install

Files:

  • Modify: cmd/archduino/main.go

  • Step 1: Add the three cobra constructors

Append near the existing newInstallCmd:

func newThemeCmd() *cobra.Command {
    var overlayPath string
    cmd := &cobra.Command{
        Use:   "theme [id]",
        Short: "Switch the active colorscheme across tools",
        Args:  cobra.MaximumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            cfg, err := config.Load(overlayPath)
            if err != nil { return err }

            // "list" short-circuit.
            if len(args) == 1 && args[0] == "list" {
                for _, t := range theming.List() {
                    fmt.Printf("%s — %s\n", t.ID, t.DisplayName)
                }
                return nil
            }

            var id string
            if len(args) == 1 {
                id = args[0]
            } else {
                // Interactive picker.
                themes := theming.List()
                opts := make([]huh.Option[string], 0, len(themes))
                for _, t := range themes { opts = append(opts, huh.NewOption(t.DisplayName, t.ID)) }
                id = cfg.ThemeOverrides["__active"]
                if id == "" && len(themes) > 0 { id = themes[0].ID }
                form := huh.NewForm(huh.NewGroup(
                    huh.NewSelect[string]().Title("Choose a theme").Options(opts...).Value(&id),
                ))
                if err := form.RunWithContext(cmd.Context()); err != nil {
                    if errors.Is(err, huh.ErrUserAborted) { return nil }
                    return err
                }
            }

            if err := theming.Apply(cfg, id); err != nil { return err }
            fmt.Printf("Applied theme: %s\n", id)
            fmt.Println("Restart zellij and neovim to see the new colors.")
            return nil
        },
    }
    cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml (defaults to ~/.config/archduino/overlay.yaml)")
    return cmd
}

func newNewCmd() *cobra.Command {
    var overlayPath string
    cmd := &cobra.Command{
        Use:   "new <project> <board>",
        Short: "Scaffold a new Arduino sketch directory",
        Args:  cobra.ExactArgs(2),
        RunE: func(cmd *cobra.Command, args []string) error {
            cfg, err := config.Load(overlayPath)
            if err != nil { return err }
            project, board := args[0], args[1]
            if err := newproject.Run(cfg, project, board); err != nil { return err }
            fmt.Printf("Created %s/ (board: %s)\n", project, board)
            fmt.Printf("Next: cd %s && nvim %s.ino\n", project, project)
            return nil
        },
    }
    cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
    return cmd
}

func newDoctorCmd() *cobra.Command {
    var overlayPath string
    cmd := &cobra.Command{
        Use:   "doctor",
        Short: "Report module convergence and pinned-tool version drift",
        Args:  cobra.NoArgs,
        RunE: func(cmd *cobra.Command, args []string) error {
            cfg, err := config.Load(overlayPath)
            if err != nil { return err }
            reg, err := buildRegistry(cfg, cfg.ThemeOverrides["__active"])
            if err != nil { return fmt.Errorf("build registry: %w", err) }
            code := doctor.Run(cmd.Context(), cfg, reg, platform.ExecRunner{}, os.Stdout)
            if code != 0 { os.Exit(code) }
            return nil
        },
    }
    cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
    return cmd
}

Add these registrations near root.AddCommand(newInstallCmd()):

root.AddCommand(newThemeCmd())
root.AddCommand(newNewCmd())
root.AddCommand(newDoctorCmd())

Add imports:

"errors"
"github.com/charmbracelet/huh"
"github.com/MDietermann/ArduinoArchSetup/internal/subcommands/doctor"
"github.com/MDietermann/ArduinoArchSetup/internal/subcommands/newproject"
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
  • Step 2: Integrate theming into install

In newInstallCmd's RunE, after the block that invokes engine.Run and assigns sum, runErr (the existing plain-path or TUI-path block), add — after any error handling that returns early and after the TUI post-wizard logic that rebuilds the registry:

            // Apply the wizard-selected theme (no-op if user chose a theme
            // whose configs are already on disk).
            if runErr == nil && !dryRun {
                selectedTheme := finalState.ThemeID
                if selectedTheme == "" { selectedTheme = cfg.ThemeOverrides["__active"] }
                if selectedTheme != "" {
                    if err := theming.Apply(cfg, selectedTheme); err != nil {
                        fmt.Fprintf(os.Stderr, "warning: theme apply failed: %v\n", err)
                    }
                }
            }

The exact variable names (finalState, runErr, dryRun) come from the existing install command code. Match them to what's currently in newInstallCmd. If the plain-output path doesn't have access to a chosen theme ID (because no wizard ran), fall through with just cfg.ThemeOverrides["__active"].

  • Step 3: Run build + tests
go build ./...
go test ./...

Both green.

  • Step 4: Manual smoke (optional, non-blocking)
go run ./cmd/archduino --help
go run ./cmd/archduino theme list
go run ./cmd/archduino doctor

Expected: help lists all four subcommands; theme list prints 5 themes; doctor reports per-module status.

  • Step 5: Commit
git add cmd/archduino/main.go
git commit -m "feat(cmd): add theme/new/doctor subcommands; install applies theme"

Self-review notes

  • Spec coverage: theming package (Tasks 1-5), tui retirement (Task 6), config extension (Task 7), newproject (Task 8), doctor (Task 9), cobra wiring + install integration (Task 10). All spec sections mapped.
  • Type consistency: theming.Theme{ID, DisplayName, NvChadTheme}, theming.List, theming.Get, theming.Apply(cfg, id), config.Board{Name, FQBN, Template}, config.Scaffolding.Boards, newproject.Run(cfg, project, board) error, doctor.Run(ctx, cfg, reg, runner, w) int, neovim.WriteChadrc(nvChadThemeName) error. Neovim New signature change in Task 6 consistently applied to main.go and tests.
  • Known approximations:
    • Task 1's _ = strings.Split is a placeholder to keep the import live for Task 2; delete if the build complains.
    • Task 5 Step 3 requires copy-paste from 4 bash files. Diff carefully — bash $variable interpolations are rare in the theme files but not impossible; any $foo in the heredoc bodies must be preserved as literal text (the heredocs use quoted delimiters 'TOML' / 'KDL' / 'YAML' which disable expansion in bash).
    • Task 6 is the highest-risk task because it touches six files. The grep in Step 1 is authoritative; do not skip it.
    • Task 10 Step 2's finalState.ThemeID depends on the current install command's variable name. If it's a different name (e.g., state), use that. Read the current code before inserting.