From b9f9f0306daca7fa6c091e1eda3d645b2a343b7e Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 09:48:35 +0200 Subject: [PATCH] docs(plans): add Plan 3b install-modules implementation plan --- .../2026-04-14-plan3b-install-modules.md | 1370 +++++++++++++++++ 1 file changed, 1370 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-plan3b-install-modules.md diff --git a/docs/superpowers/plans/2026-04-14-plan3b-install-modules.md b/docs/superpowers/plans/2026-04-14-plan3b-install-modules.md new file mode 100644 index 0000000..5502441 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-plan3b-install-modules.md @@ -0,0 +1,1370 @@ +# Plan 3b — Install Modules (Heavy Tier) 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:** Port the `neovim` and `renode` Bash installers to Go modules, add one shared `WriteUserFile` helper, extend `tui.Theme` so the wizard's theme selection flows into NvChad, and register the two modules in the install command. + +**Architecture:** Five tasks of foundation (WriteUserFile helper, theme field, config extension, buildRegistry theme-plumbing, and the neovim-starter sed-strip fixture), then one task per module, then registration + smoke. Assets (Lua files, `arduino-sim` bash) live under `internal/modules//assets/` and are bundled via `go:embed`. + +**Tech Stack:** Go stdlib only (`embed`, `text/template`, `os`, `path/filepath`). Depends on Plan 3a helpers (pacman, AUR, download, extract, git) and on `internal/module` / `internal/engine` / `internal/tui` from Plans 1-2. + +--- + +## Reference: existing surfaces + +From Plan 3a / earlier work: + +```go +// internal/module/module.go +type Module interface { + Meta() Meta + Detect(ctx) (State, error) + Plan(ctx, State) (Plan, error) + Apply(ctx, Plan, Reporter) error + Verify(ctx) error +} + +// internal/platform +type PackageManager interface { Name() string; IsInstalled(ctx, pkg string) (bool, error); Install(ctx, pkgs []string) error; Update(ctx) error } +type AURHelper interface { EnsureInstalled(ctx) error; Install(ctx, pkgs []string) error } +type Runner interface { Run(ctx, name string, args ...string) error; Output(ctx, name string, args ...string) ([]byte, error) } +type GitClient struct { Runner Runner } +func Download(ctx, url, destPath string, mode os.FileMode) error + +// internal/tui/state.go (before this plan) +type Theme struct { ID, Name string } +var BuiltinThemes = []Theme{ {ID: "gruvbox", Name: "Gruvbox Dark"}, ... } + +// cmd/archduino/main.go (before this plan) +func buildRegistry(cfg config.Config) (*module.Registry, error) +``` + +Plan-3a fakeRunner pattern (replicate inside each new module's `_test.go` — don't share): + +```go +type fakeRunner struct { + runCalls, outCalls [][]string + installed map[string]bool +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)); return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { return []byte("ok"), nil } + return nil, errExit1 + } + return nil, nil +} +type exitOne struct{} +func (exitOne) Error() string { return "exit 1" } +var errExit1 = exitOne{} +``` + +--- + +## Task 1: `WriteUserFile` platform helper + +**Files:** +- Create: `internal/platform/writefile.go` +- Create: `internal/platform/writefile_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package platform + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteUserFileCreatesParent(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "a", "b", "c.txt") + if err := WriteUserFile(dest, []byte("hi"), 0o644); err != nil { t.Fatal(err) } + got, _ := os.ReadFile(dest) + if string(got) != "hi" { t.Fatalf("content %q", got) } + info, _ := os.Stat(dest) + if info.Mode().Perm() != 0o644 { t.Fatalf("mode %v", info.Mode().Perm()) } +} + +func TestWriteUserFileOverwritesAtomically(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "x") + if err := WriteUserFile(dest, []byte("one"), 0o644); err != nil { t.Fatal(err) } + if err := WriteUserFile(dest, []byte("two"), 0o644); err != nil { t.Fatal(err) } + got, _ := os.ReadFile(dest) + if string(got) != "two" { t.Fatalf("got %q want two", got) } + // No .tmp sibling should be left behind. + if _, err := os.Stat(dest + ".tmp"); !os.IsNotExist(err) { + t.Fatalf(".tmp left behind: %v", err) + } +} + +func TestWriteUserFileExecMode(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "bin") + if err := WriteUserFile(dest, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } + info, _ := os.Stat(dest) + if info.Mode().Perm() != 0o755 { t.Fatalf("mode %v want 0755", info.Mode().Perm()) } +} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/platform/... -run TestWriteUserFile -v +``` +Expected: `undefined: WriteUserFile`. + +- [ ] **Step 3: Implement `writefile.go`** + +```go +package platform + +import ( + "os" + "path/filepath" +) + +// WriteUserFile writes data to path atomically (tmp + rename) with the +// given mode, creating any missing parent directories (mode 0755). +// Intended for user-owned destinations under $HOME — no sudo. +func WriteUserFile(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, mode); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +``` +go test ./internal/platform/... -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/platform/writefile.go internal/platform/writefile_test.go +git commit -m "feat(platform): add WriteUserFile atomic user-space writer" +``` + +--- + +## Task 2: Extend `tui.Theme` with NvChad name + lookup helper + +**Files:** +- Modify: `internal/tui/state.go` +- Modify: `internal/tui/state_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/tui/state_test.go`: + +```go +func TestBuiltinThemesCarryNvChadName(t *testing.T) { + for _, th := range BuiltinThemes { + if th.NvChadTheme == "" { + t.Errorf("theme %q has empty NvChadTheme", th.ID) + } + } +} + +func TestThemeByIDReturnsMatch(t *testing.T) { + got, ok := ThemeByID(BuiltinThemes, "dracula") + if !ok { t.Fatal("dracula should exist") } + if got.ID != "dracula" { t.Fatalf("got id=%q", got.ID) } + if got.NvChadTheme == "" { t.Fatal("NvChadTheme empty") } +} + +func TestThemeByIDFallsBackWhenUnknown(t *testing.T) { + _, ok := ThemeByID(BuiltinThemes, "does-not-exist") + if ok { t.Fatal("unknown id should report ok=false") } +} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/tui/... -run TestBuiltinThemes -v +``` +Expected: `th.NvChadTheme undefined` / `undefined: ThemeByID`. + +- [ ] **Step 3: Implement** + +In `internal/tui/state.go`, replace the `Theme` struct and `BuiltinThemes` list: + +```go +// Theme is a named colorscheme presented in the wizard. Each entry carries +// per-tool theme names; Plan 3b uses NvChadTheme, later plans add more. +type Theme struct { + ID string + Name string + NvChadTheme string +} + +var BuiltinThemes = []Theme{ + {ID: "gruvbox", Name: "Gruvbox Dark", NvChadTheme: "gruvbox"}, + {ID: "catppuccin", Name: "Catppuccin Mocha", NvChadTheme: "catppuccin"}, + {ID: "tokyonight", Name: "Tokyo Night", NvChadTheme: "tokyonight"}, + {ID: "nord", Name: "Nord", NvChadTheme: "nord"}, + {ID: "dracula", Name: "Dracula", NvChadTheme: "dracula"}, +} + +// ThemeByID returns the Theme with the given ID. The bool is false when no +// match is found; callers decide whether to fall back or error. +func ThemeByID(themes []Theme, id string) (Theme, bool) { + for _, t := range themes { + if t.ID == id { return t, true } + } + return Theme{}, false +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +``` +go test ./internal/tui/... -v +go test ./... +``` +Both must be green — tui is imported by the wizard and cmd. + +- [ ] **Step 5: Commit** + +```bash +git add internal/tui/state.go internal/tui/state_test.go +git commit -m "feat(tui): add NvChadTheme field and ThemeByID helper" +``` + +--- + +## Task 3: Extend `config` with NeovimConfig and RenodeConfig + +**Files:** +- Modify: `internal/config/config.go` +- Modify: `internal/config/defaults.yaml` +- Modify: `internal/config/config_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/config/config_test.go`: + +```go +func TestDefaultsCarryPlan3bInputs(t *testing.T) { + cfg, err := Load("/nonexistent/overlay.yaml") + if err != nil { t.Fatal(err) } + if cfg.Neovim.NvChadStarterRepo == "" { t.Error("Neovim.NvChadStarterRepo empty") } + if cfg.Renode.WokwiVersion == "" { t.Error("Renode.WokwiVersion empty") } + if cfg.Renode.WokwiURL == "" { t.Error("Renode.WokwiURL empty") } + if len(cfg.Renode.Platforms) == 0 { t.Error("Renode.Platforms empty") } + want := map[string]string{"stm32f4": "STM32F4_Discovery", "rp2040": "RP2040", "nrf52840": "nRF52840"} + for _, p := range cfg.Renode.Platforms { + if want[p.Name] != p.Machine { + t.Errorf("platform %q: machine=%q want %q", p.Name, p.Machine, want[p.Name]) + } + } +} +``` + +- [ ] **Step 2: Run test, confirm fail** + +``` +go test ./internal/config/... -run TestDefaultsCarryPlan3bInputs -v +``` +Expected: `cfg.Neovim undefined`. + +- [ ] **Step 3: Extend `internal/config/config.go`** + +Add new typed structs above `Config`: + +```go +type NeovimConfig struct { + NvChadStarterRepo string `yaml:"nvchad_starter_repo"` +} + +type RenodePlatform struct { + Name string `yaml:"name"` + ReplPath string `yaml:"repl_path"` + Machine string `yaml:"machine"` +} + +type RenodeConfig struct { + WokwiVersion string `yaml:"wokwi_version"` + WokwiURL string `yaml:"wokwi_url"` + Platforms []RenodePlatform `yaml:"platforms"` +} +``` + +Add the two fields to `Config`: + +```go + Neovim NeovimConfig `yaml:"neovim"` + Renode RenodeConfig `yaml:"renode"` +``` + +Extend `merge` with: + +```go + if over.Neovim.NvChadStarterRepo != "" { out.Neovim = over.Neovim } + if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 { out.Renode = over.Renode } +``` + +- [ ] **Step 4: Extend `defaults.yaml`** + +Append (after existing `shell_config:` block): + +```yaml + +neovim: + nvchad_starter_repo: "https://github.com/NvChad/starter" + +renode: + wokwi_version: "0.28.1" + wokwi_url: "https://github.com/wokwi/wokwi-cli/releases/download/v0.28.1/wokwi-cli-linuxstatic-x64" + platforms: + - name: stm32f4 + repl_path: "platforms/boards/stm32f4_discovery.repl" + machine: "STM32F4_Discovery" + - name: rp2040 + repl_path: "platforms/cpus/rp2040.repl" + machine: "RP2040" + - name: nrf52840 + repl_path: "platforms/cpus/nrf52840.repl" + machine: "nRF52840" +``` + +- [ ] **Step 5: Run tests, confirm pass** + +``` +go test ./internal/config/... -v +go test ./... +``` + +- [ ] **Step 6: Commit** + +```bash +git add internal/config/config.go internal/config/defaults.yaml internal/config/config_test.go +git commit -m "feat(config): add Neovim and Renode typed inputs" +``` + +--- + +## Task 4: `buildRegistry` accepts a theme ID + +**Files:** +- Modify: `cmd/archduino/main.go` + +This task doesn't register the new modules yet — just changes the signature and updates all call sites, so Task 7's wiring is a one-line-per-module addition. + +- [ ] **Step 1: Read current call sites** + +``` +grep -n "buildRegistry(" cmd/archduino/main.go +``` +Expected: two call sites (plain-output path and TUI path). Note the line numbers. + +- [ ] **Step 2: Change `buildRegistry` signature and add theme param** + +Replace the function signature: + +```go +func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) { +``` + +Leave the body unchanged for this task; `themeID` is unused until Task 5. Add a `_ = themeID` inside to avoid an "unused parameter" lint if any linter is strict (Go's stdlib tolerates unused parameters — no lint needed, but be explicit if the subagent's linter complains). + +- [ ] **Step 3: Update both call sites** + +First call site — plain-output and pre-wizard build. Change: + +```go +reg, err := buildRegistry(cfg) +``` + +to: + +```go +reg, err := buildRegistry(cfg, tui.BuiltinThemes[0].ID) +``` + +Add a TUI-path rebuild **after** the wizard returns. Locate the block (around line 175-200 of main.go) where the wizard's result is consumed before the dashboard runs. After the wizard returns `state, confirmed, err` (or equivalent), add: + +```go +// Rebuild the registry with the user-selected theme so modules like +// neovim see the chosen NvChad theme before Apply runs. +reg, err = buildRegistry(cfg, state.ThemeID) +if err != nil { + return fmt.Errorf("rebuild registry for dashboard: %w", err) +} +``` + +(Exact variable names — `state`, `confirmed` — come from the existing wizard call. Match them to the current code.) + +- [ ] **Step 4: Run tests** + +``` +go build ./... +go test ./... +``` +Expected: green. Signature change is backward-compatible with themeID unused. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/archduino/main.go +git commit -m "refactor(cmd): thread theme ID through buildRegistry" +``` + +--- + +## Task 5: Neovim module (scaffold, assets, backup, strip-lspconfig) + +**Files:** +- Create: `internal/modules/neovim/neovim.go` +- Create: `internal/modules/neovim/neovim_test.go` +- Create: `internal/modules/neovim/assets/chadrc.lua.tmpl` +- Create: `internal/modules/neovim/assets/arduino.lua` +- Create: `internal/modules/neovim/assets/clangd.lua` +- Create: `internal/modules/neovim/assets/lspconfig.lua` +- Create: `internal/modules/neovim/assets/dap.lua` +- Create: `internal/modules/neovim/assets/mappings.lua` + +This is the biggest task. Complete it in order of the steps below — do not reorder. + +- [ ] **Step 1: Create the asset files** + +Copy these files verbatim into `internal/modules/neovim/assets/`. Content comes from the heredocs in `lib/neovim.sh` (main repo root — NOT inside the worktree). + +`chadrc.lua.tmpl`: + +``` +---@type ChadrcConfig +local M = {} + +M.base46 = { + theme = "{{.NvChadTheme}}", +} + +return M +``` + +`arduino.lua` (copy lines 151-163 of `lib/neovim.sh`, i.e. the `return { { "stevearc/vim-arduino", ... } }` block): + +``` +return { + { + "stevearc/vim-arduino", + ft = { "arduino", "cpp", "c" }, + config = function() + vim.g.arduino_use_cli = 1 + vim.g.arduino_cli_path = "arduino-cli" + end, + }, +} +``` + +`clangd.lua`: + +``` +return { + cmd = { + "clangd", + "--background-index", + "--clang-tidy", + "--header-insertion=iwyu", + "--completion-style=detailed", + }, + filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" }, +} +``` + +`lspconfig.lua`: + +``` +vim.lsp.enable("clangd") +return {} +``` + +`dap.lua` — copy the full dap plugin block from `lib/neovim.sh` (the `return { { "mfussenegger/nvim-dap", ... }, { "rcarriga/nvim-dap-ui", ... }, { "nvim-neotest/nvim-nio", lazy = true } }` structure). + +`mappings.lua` — copy the full mappings block from `lib/neovim.sh` (starts with `require "nvchad.mappings"`, includes all the `a*` and `d*` bindings). + +(Substitute the exact bash-heredoc contents — do not paraphrase.) + +- [ ] **Step 2: Write the failing test** + +```go +package neovim + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +type fakeRunner struct { + runCalls, outCalls [][]string + installed map[string]bool +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + if name == "git" && len(args) >= 1 && args[0] == "clone" { + // Simulate a successful clone by creating the dest dir + a starter init.lua. + dest := args[len(args)-1] + os.MkdirAll(filepath.Join(dest, "lua", "plugins"), 0o755) + os.WriteFile(filepath.Join(dest, "lua", "plugins", "init.lua"), + []byte("return {\n \"neovim/nvim-lspconfig\",\n opts = {},\n},\n{\n \"other\",\n},\n"), + 0o644) + os.MkdirAll(filepath.Join(dest, "lua", "configs"), 0o755) + os.WriteFile(filepath.Join(dest, "lua", "configs", "lspconfig.lua"), []byte("stub"), 0o644) + } + return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { return []byte("ok"), nil } + return nil, errExit1 + } + return nil, nil +} +type exitOne struct{} +func (exitOne) Error() string { return "exit 1" } +var errExit1 = exitOne{} + +type nopReporter struct{} +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestDetectFreshSystem(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, err := m.Detect(context.Background()); if err != nil { t.Fatal(err) } + s := st.(state) + if s.NvimInstalled { t.Error("nvim should be absent") } + if s.ChadrcPresent { t.Error("chadrc should be absent") } + if s.BackupNeeded { t.Error("no backup on empty system") } +} + +func TestDetectNonNvChadTriggersBackup(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + // Create a non-NvChad config: lua/ dir present, no chadrc.lua. + os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755) + os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + if !st.(state).BackupNeeded { t.Fatal("expected BackupNeeded=true") } +} + +func TestPlanApplyInstallsEverything(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if len(p.Actions) == 0 { t.Fatal("empty plan") } + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { t.Fatal(err) } + + sawPacman, sawClone := false, false + for _, c := range r.runCalls { + if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" { sawPacman = true } + if c[0] == "git" && len(c) >= 2 && c[1] == "clone" { sawClone = true } + } + if !sawPacman || !sawClone { t.Fatalf("missing steps; calls=%v", r.runCalls) } + + nvimCfg := filepath.Join(home, ".config", "nvim") + // chadrc rendered with selected theme + chadrc, err := os.ReadFile(filepath.Join(nvimCfg, "lua", "chadrc.lua")) + if err != nil { t.Fatalf("chadrc missing: %v", err) } + if !strings.Contains(string(chadrc), `theme = "dracula"`) { t.Fatalf("chadrc theme not applied: %s", chadrc) } + + // lspconfig block stripped from starter init.lua + initLua, _ := os.ReadFile(filepath.Join(nvimCfg, "lua", "plugins", "init.lua")) + if strings.Contains(string(initLua), "nvim-lspconfig") { + t.Fatalf("nvim-lspconfig not stripped: %s", initLua) + } + + // starter's lspconfig.lua removed + if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "configs", "lspconfig.lua")); !os.IsNotExist(err) { + t.Fatal("starter lspconfig.lua should be removed") + } + + // Static assets written + for _, name := range []string{"arduino.lua", "dap.lua", "mappings.lua"} { + if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "plugins", name)); err != nil { + t.Errorf("asset %s missing: %v", name, err) + } + } + if _, err := os.Stat(filepath.Join(nvimCfg, "lsp", "clangd.lua")); err != nil { + t.Errorf("clangd.lua missing: %v", err) + } +} + +func TestPlanAfterApplyIsEmpty(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{"neovim": true}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + _ = m.Apply(context.Background(), p, nopReporter{}) + st2, _ := m.Detect(context.Background()) + p2, _ := m.Plan(context.Background(), st2) + if !p2.IsEmpty() { t.Fatalf("second plan not empty: %+v", p2.Actions) } +} + +func TestUnknownThemeFallsBackToFirst(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{"neovim": true}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { t.Fatal(err) } + chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")) + if !strings.Contains(string(chadrc), `theme = "gruvbox"`) { + t.Fatalf("fallback theme not applied: %s", chadrc) + } +} + +var _ module.Module = Module{} +``` + +- [ ] **Step 3: Run test, confirm fail** + +``` +go test ./internal/modules/neovim/... -v +``` +Expected: `undefined: New`. + +- [ ] **Step 4: Implement `neovim.go`** + +```go +// Package neovim installs Neovim via pacman, clones the NvChad starter, +// backs up any non-NvChad config, strips the starter's nvim-lspconfig +// block, and writes Archduino's Lua asset files. +package neovim + +import ( + "bytes" + "context" + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" + "time" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +//go:embed assets/* +var assets embed.FS + +// assetMap lists each asset and its destination under ~/.config/nvim. +// Order matters: Detect iterates in this order and Apply writes in this order. +var assetMap = []struct { + SrcPath string // under assets/ + DstRel string // under ~/.config/nvim/ +}{ + {"assets/arduino.lua", "lua/plugins/arduino.lua"}, + {"assets/dap.lua", "lua/plugins/dap.lua"}, + {"assets/lspconfig.lua", "lua/plugins/lspconfig.lua"}, + {"assets/mappings.lua", "lua/mappings.lua"}, + {"assets/clangd.lua", "lsp/clangd.lua"}, +} + +type Module struct { + cfg config.NeovimConfig + themeID string + themes []tui.Theme + pm platform.PackageManager + git platform.GitClient +} + +func New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module { + return Module{cfg: cfg, themeID: themeID, themes: themes, pm: pm, git: git} +} + +type state struct { + NvimInstalled bool + ConfigDirExists bool + ChadrcPresent bool + BackupNeeded bool + BackupSource string + StarterCloned bool // chadrc.lua OR lua/plugins/init.lua implies starter present + MissingAssets []string + ChadrcDrift bool + LspconfigBlock bool // true if starter init.lua still contains the nvim-lspconfig block + StarterLspFile bool // true if lua/configs/lspconfig.lua from starter still exists + ThemeName string + RenderedChadrc []byte +} + +func (Module) Meta() module.Meta { + return module.Meta{ID: "neovim", Name: "Neovim + NvChad", Description: "Install Neovim and configure NvChad with Archduino plugins."} +} + +func nvimCfg() (string, error) { + h, err := os.UserHomeDir(); if err != nil { return "", err } + return filepath.Join(h, ".config", "nvim"), nil +} + +func (m Module) resolvedTheme() string { + if t, ok := tui.ThemeByID(m.themes, m.themeID); ok && t.NvChadTheme != "" { + return t.NvChadTheme + } + if len(m.themes) > 0 { return m.themes[0].NvChadTheme } + return "gruvbox" +} + +func (m Module) renderChadrc() ([]byte, error) { + tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl"); if err != nil { return nil, err } + tmpl, err := template.New("chadrc").Parse(string(tmplBytes)); if err != nil { return nil, err } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: m.resolvedTheme()}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + cfg, err := nvimCfg(); if err != nil { return nil, err } + var s state + s.ThemeName = m.resolvedTheme() + if m.pm != nil { + ok, err := m.pm.IsInstalled(ctx, "neovim"); if err != nil { return nil, err } + s.NvimInstalled = ok + } + + rendered, err := m.renderChadrc(); if err != nil { return nil, err } + s.RenderedChadrc = rendered + + if fi, err := os.Stat(cfg); err == nil && fi.IsDir() { + s.ConfigDirExists = true + if _, err := os.Stat(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil { + s.ChadrcPresent = true + } + if _, err := os.Stat(filepath.Join(cfg, "lua")); err == nil && !s.ChadrcPresent { + s.BackupNeeded = true + s.BackupSource = cfg + } + } + if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", "init.lua")); err == nil { + s.StarterCloned = true + data, _ := os.ReadFile(filepath.Join(cfg, "lua", "plugins", "init.lua")) + if bytes.Contains(data, []byte("nvim-lspconfig")) { + s.LspconfigBlock = true + } + } + if _, err := os.Stat(filepath.Join(cfg, "lua", "configs", "lspconfig.lua")); err == nil { + s.StarterLspFile = true + } + + // Chadrc drift + if cur, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil { + if !bytes.Equal(cur, rendered) { s.ChadrcDrift = true } + } + + // Missing static assets + for _, a := range assetMap { + want, _ := assets.ReadFile(a.SrcPath) + got, err := os.ReadFile(filepath.Join(cfg, a.DstRel)) + if err != nil || !bytes.Equal(got, want) { + s.MissingAssets = append(s.MissingAssets, a.DstRel) + } + } + return s, nil +} + +var lspconfigBlockRE = regexp.MustCompile(`(?ms)\{[^{}]*"neovim/nvim-lspconfig"[^{}]*\},?\s*`) + +func stripLspconfigBlock(src []byte) []byte { + return lspconfigBlockRE.ReplaceAll(src, []byte("")) +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + cfg, err := nvimCfg(); if err != nil { return module.Plan{}, err } + var actions []module.Action + + if !s.NvimInstalled && m.pm != nil { + actions = append(actions, module.Action{ + Description: "pacman install neovim", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"neovim"}) }, + }) + } + + if s.BackupNeeded { + src := s.BackupSource + actions = append(actions, module.Action{ + Description: "back up existing non-NvChad config", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + ts := time.Now().UTC().Format("20060102T150405Z") + return os.Rename(src, src+".bak."+ts) + }, + }) + } + + if !s.StarterCloned || s.BackupNeeded { + repo := m.cfg.NvChadStarterRepo + actions = append(actions, module.Action{ + Description: "clone NvChad starter", + Kind: module.ActionGitClone, + Exec: func(ctx context.Context) error { + if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil { return err } + if err := m.git.Clone(ctx, repo, cfg); err != nil { return err } + return os.RemoveAll(filepath.Join(cfg, ".git")) + }, + }) + } + + // These next two run after clone — they edit starter output. + if s.LspconfigBlock || !s.StarterCloned || s.BackupNeeded { + actions = append(actions, module.Action{ + Description: "strip nvim-lspconfig block from starter init.lua", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + p := filepath.Join(cfg, "lua", "plugins", "init.lua") + data, err := os.ReadFile(p) + if err != nil { return err } + return platform.WriteUserFile(p, stripLspconfigBlock(data), 0o644) + }, + }) + } + + if s.StarterLspFile || !s.StarterCloned || s.BackupNeeded { + actions = append(actions, module.Action{ + Description: "remove starter lspconfig.lua", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + return os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua")) + }, + }) + } + + if s.ChadrcDrift || !s.ChadrcPresent || s.BackupNeeded { + rendered := append([]byte{}, s.RenderedChadrc...) + actions = append(actions, module.Action{ + Description: "write chadrc.lua with theme " + s.ThemeName, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + return platform.WriteUserFile(filepath.Join(cfg, "lua", "chadrc.lua"), rendered, 0o644) + }, + }) + } + + for _, dst := range s.MissingAssets { + dst := dst + actions = append(actions, module.Action{ + Description: "write asset " + dst, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + var src string + for _, a := range assetMap { + if a.DstRel == dst { src = a.SrcPath; break } + } + data, err := assets.ReadFile(src); if err != nil { return err } + return platform.WriteUserFile(filepath.Join(cfg, dst), data, 0o644) + }, + }) + } + + // Theme cache clear (idempotent RemoveAll) — always safe on re-apply. + if len(actions) > 0 { + actions = append(actions, module.Action{ + Description: "clear NvChad base46 theme cache", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + home, _ := os.UserHomeDir() + _ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes")) + _ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46")) + return nil + }, + }) + } + + _ = fs.ErrNotExist // keep io/fs import live in case future additions need it + _ = strings.Join // ditto for strings + return module.Plan{Actions: actions}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { return err } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx); if err != nil { return err } + s := st.(state) + if !s.NvimInstalled { return fmt.Errorf("neovim not installed") } + if !s.ChadrcPresent { return fmt.Errorf("chadrc.lua missing") } + if s.ChadrcDrift { return fmt.Errorf("chadrc.lua content drifted") } + if len(s.MissingAssets) > 0 { return fmt.Errorf("missing assets: %v", s.MissingAssets) } + if s.LspconfigBlock { return fmt.Errorf("nvim-lspconfig block not stripped") } + if s.StarterLspFile { return fmt.Errorf("starter lspconfig.lua still present") } + return nil +} +``` + +Note to the implementer: the `_ = fs.ErrNotExist` / `_ = strings.Join` lines exist to keep imports; delete them if the final implementation uses those packages naturally (it may, for path-resolving edge cases). If the build complains about unused imports, drop the ones not used. + +- [ ] **Step 5: Run tests, confirm pass** + +``` +go test ./internal/modules/neovim/... -v +go test ./... +``` + +If `TestPlanApplyInstallsEverything` fails with "nvim-lspconfig not stripped", adjust `lspconfigBlockRE` — the starter's real `init.lua` wraps the block across multiple lines; the regex must be multiline and non-greedy. The provided regex `(?ms)\{[^{}]*"neovim/nvim-lspconfig"[^{}]*\},?\s*` handles a single-level block. If the real starter nests braces inside, switch to a line-range strategy: find the start line containing `"neovim/nvim-lspconfig"`, scan backward to the opening `{`, scan forward to the matching `},`, delete that range. + +- [ ] **Step 6: Commit** + +```bash +git add internal/modules/neovim/ +git commit -m "feat(modules): port neovim module (NvChad + Archduino Lua assets) to Go" +``` + +--- + +## Task 6: Renode module (yay + pacman + arduino-sim + wokwi) + +**Files:** +- Create: `internal/modules/renode/renode.go` +- Create: `internal/modules/renode/renode_test.go` +- Create: `internal/modules/renode/assets/arduino-sim.tmpl` + +- [ ] **Step 1: Create the asset template** + +`internal/modules/renode/assets/arduino-sim.tmpl` — the bash helper from `lib/renode.sh` with the case-statement generated from config. Contents: + +``` +#!/bin/bash +set -euo pipefail + +usage() { + echo "Usage: arduino-sim [platform]" + echo "" + echo "Launch Renode with a GDB server for debugging firmware." + echo "Connect nvim-dap or gdb to localhost:3333" + echo "" + echo "Platforms:" +{{range .Platforms}} echo " {{.Name}} {{.Machine}}" +{{end}} exit 1 +} + +[[ $# -lt 1 ]] && usage + +ELF="$1" +PLATFORM="${2:-{{(index .Platforms 0).Name}}}" + +[[ ! -f "$ELF" ]] && echo "Error: $ELF not found" && exit 1 + +case "$PLATFORM" in +{{range .Platforms}} {{.Name}}) + RENODE_PLATFORM="{{.ReplPath}}" + MACHINE="{{.Machine}}" + ;; +{{end}} *) + echo "Unknown platform: $PLATFORM" + usage + ;; +esac + +RESC=$(mktemp /tmp/renode-XXXXXX.resc) +cat >"$RESC" <ds or gdb → target remote :3333" +echo "" + +renode --disable-xwt "$RESC" +rm -f "$RESC" +``` + +- [ ] **Step 2: Write the failing test** + +```go +package renode + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls, outCalls [][]string + installed map[string]bool +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)); return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { return []byte("ok"), nil } + return nil, errExit1 + } + return nil, nil +} +type exitOne struct{} +func (exitOne) Error() string { return "exit 1" } +var errExit1 = exitOne{} + +type nopReporter struct{} +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func sampleCfg(url string) config.RenodeConfig { + return config.RenodeConfig{ + WokwiVersion: "0.28.1", + WokwiURL: url, + Platforms: []config.RenodePlatform{ + {Name: "stm32f4", ReplPath: "platforms/boards/stm32f4_discovery.repl", Machine: "STM32F4_Discovery"}, + {Name: "rp2040", ReplPath: "platforms/cpus/rp2040.repl", Machine: "RP2040"}, + }, + } +} + +func TestDetectNothingInstalled(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r)) + st, err := m.Detect(context.Background()); if err != nil { t.Fatal(err) } + s := st.(state) + if s.RenodeInstalled || s.GdbInstalled || s.SimScriptCurrent || s.WokwiInstalled { + t.Fatalf("want all absent, got %+v", s) + } +} + +func TestDetectAllInstalled(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755) + os.WriteFile(filepath.Join(home, ".local", "bin", "wokwi-cli"), []byte("x"), 0o755) + r := &fakeRunner{installed: map[string]bool{"renode-bin": true, "arm-none-eabi-gdb": true}} + m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r)) + // Apply once to seed the sim script, then re-detect. + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + _ = m.Apply(context.Background(), p, nopReporter{}) + st2, _ := m.Detect(context.Background()) + p2, _ := m.Plan(context.Background(), st2) + if !p2.IsEmpty() { t.Fatalf("second plan not empty: %+v", p2.Actions) } +} + +func TestApplyRendersSimScriptAndDownloadsWokwi(t *testing.T) { + home := t.TempDir(); t.Setenv("HOME", home) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("wokwi-binary")) + })) + defer srv.Close() + + r := &fakeRunner{installed: map[string]bool{"yay": true}} + m := New(sampleCfg(srv.URL), platform.NewPacman(r), platform.NewYay(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { t.Fatal(err) } + + // arduino-sim rendered with both platforms + sim, err := os.ReadFile(filepath.Join(home, ".local", "bin", "arduino-sim")) + if err != nil { t.Fatalf("arduino-sim not written: %v", err) } + if !strings.Contains(string(sim), "stm32f4)") { t.Errorf("stm32f4 branch missing: %s", sim) } + if !strings.Contains(string(sim), "rp2040)") { t.Errorf("rp2040 branch missing: %s", sim) } + info, _ := os.Stat(filepath.Join(home, ".local", "bin", "arduino-sim")) + if info.Mode().Perm() != 0o755 { t.Errorf("arduino-sim mode %v", info.Mode().Perm()) } + + // wokwi-cli downloaded + wokwi, err := os.ReadFile(filepath.Join(home, ".local", "bin", "wokwi-cli")) + if err != nil { t.Fatalf("wokwi-cli missing: %v", err) } + if string(wokwi) != "wokwi-binary" { t.Errorf("wokwi-cli content %q", wokwi) } + + // yay install renode-bin called + sawYay := false + for _, c := range r.runCalls { + if c[0] == "yay" && len(c) >= 2 && c[1] == "-S" { sawYay = true } + } + if !sawYay { t.Fatalf("yay install missing; calls=%v", r.runCalls) } +} + +var _ module.Module = Module{} +``` + +- [ ] **Step 3: Run test, confirm fail** + +``` +go test ./internal/modules/renode/... -v +``` +Expected: `undefined: New`. + +- [ ] **Step 4: Implement `renode.go`** + +```go +// Package renode installs the Renode emulator (AUR), arm-none-eabi-gdb, +// the arduino-sim helper script, and wokwi-cli. +package renode + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "path/filepath" + "text/template" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +//go:embed assets/arduino-sim.tmpl +var assetsFS embed.FS + +type Module struct { + cfg config.RenodeConfig + pm platform.PackageManager + aur platform.AURHelper +} + +func New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module { + return Module{cfg: cfg, pm: pm, aur: aur} +} + +type state struct { + RenodeInstalled bool + GdbInstalled bool + SimScriptCurrent bool + WokwiInstalled bool + RenderedSim []byte + SimPath string + WokwiPath string +} + +func (Module) Meta() module.Meta { + return module.Meta{ID: "renode", Name: "Renode emulator", Description: "Install Renode, ARM GDB, arduino-sim helper, and Wokwi CLI."} +} + +func binPath() (string, error) { + h, err := os.UserHomeDir(); if err != nil { return "", err } + return filepath.Join(h, ".local", "bin"), nil +} + +func (m Module) renderSim() ([]byte, error) { + tmplBytes, err := assetsFS.ReadFile("assets/arduino-sim.tmpl"); if err != nil { return nil, err } + tmpl, err := template.New("sim").Parse(string(tmplBytes)); if err != nil { return nil, err } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, m.cfg); err != nil { return nil, err } + return buf.Bytes(), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + bin, err := binPath(); if err != nil { return nil, err } + s := state{ + SimPath: filepath.Join(bin, "arduino-sim"), + WokwiPath: filepath.Join(bin, "wokwi-cli"), + } + if ok, err := m.pm.IsInstalled(ctx, "renode-bin"); err == nil && ok { s.RenodeInstalled = true } + if ok, err := m.pm.IsInstalled(ctx, "arm-none-eabi-gdb"); err == nil && ok { s.GdbInstalled = true } + + rendered, err := m.renderSim(); if err != nil { return nil, err } + s.RenderedSim = rendered + if cur, err := os.ReadFile(s.SimPath); err == nil && bytes.Equal(cur, rendered) { + s.SimScriptCurrent = true + } + if _, err := os.Stat(s.WokwiPath); err == nil { s.WokwiInstalled = true } + return s, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + var actions []module.Action + + if !s.RenodeInstalled { + actions = append(actions, module.Action{ + Description: "ensure yay installed", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) }, + }) + actions = append(actions, module.Action{ + Description: "yay install renode-bin", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.aur.Install(ctx, []string{"renode-bin"}) }, + }) + } + if !s.GdbInstalled { + actions = append(actions, module.Action{ + Description: "pacman install arm-none-eabi-gdb", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"arm-none-eabi-gdb"}) }, + }) + } + if !s.SimScriptCurrent { + rendered := append([]byte{}, s.RenderedSim...) + path := s.SimPath + actions = append(actions, module.Action{ + Description: "write arduino-sim helper", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + return platform.WriteUserFile(path, rendered, 0o755) + }, + }) + } + if !s.WokwiInstalled { + url := m.cfg.WokwiURL + dst := s.WokwiPath + actions = append(actions, module.Action{ + Description: "download wokwi-cli " + m.cfg.WokwiVersion, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } + return platform.Download(ctx, url, dst, 0o755) + }, + }) + } + return module.Plan{Actions: actions}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { return err } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx); if err != nil { return err } + s := st.(state) + if !s.RenodeInstalled { return fmt.Errorf("renode-bin not installed") } + if !s.GdbInstalled { return fmt.Errorf("arm-none-eabi-gdb not installed") } + if !s.SimScriptCurrent { return fmt.Errorf("arduino-sim script drift") } + if !s.WokwiInstalled { return fmt.Errorf("wokwi-cli missing") } + return nil +} +``` + +- [ ] **Step 5: Run tests, confirm pass** + +``` +go test ./internal/modules/renode/... -v +go test ./... +``` + +- [ ] **Step 6: Commit** + +```bash +git add internal/modules/renode/ +git commit -m "feat(modules): port renode module (AUR + arduino-sim + wokwi-cli) to Go" +``` + +--- + +## Task 7: Register neovim + renode in `buildRegistry` + +**Files:** +- Modify: `cmd/archduino/main.go` + +- [ ] **Step 1: Update `buildRegistry`** + +Locate the `mods := []module.Module{ ... }` slice (currently ending with `shellconfig.New(cfg.ShellConfig)`). Append two entries: + +```go + neovim.New(cfg.Neovim, themeID, tui.BuiltinThemes, pm, git), + renode.New(cfg.Renode, pm, yay), +``` + +Add imports at the top: + +```go + "github.com/MDietermann/ArduinoArchSetup/internal/modules/neovim" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/renode" +``` + +(`tui` is already imported.) + +- [ ] **Step 2: Run build + tests** + +``` +go build ./... +go test ./... +``` +Both green. + +- [ ] **Step 3: Manual smoke — dry Detect/Plan only** + +``` +go run ./cmd/archduino install --dry-run --no-tui --only neovim,renode +``` +Expected: both modules report their plan actions. No Apply runs in `--dry-run`. + +If `--dry-run` / `--only` flags don't exist on the install subcommand yet, skip this step — the full suite passing is sufficient verification. + +- [ ] **Step 4: Commit** + +```bash +git add cmd/archduino/main.go +git commit -m "feat(cmd): register neovim and renode modules in install command" +``` + +--- + +## Self-review notes + +- Spec coverage: WriteUserFile (Task 1), theme extension (Task 2), config (Task 3), buildRegistry plumbing (Task 4), neovim (Task 5), renode (Task 6), registration (Task 7). All spec sections mapped. +- Type consistency: `NeovimConfig.NvChadStarterRepo`, `RenodeConfig.{WokwiVersion, WokwiURL, Platforms}`, `RenodePlatform.{Name, ReplPath, Machine}`, `tui.Theme.NvChadTheme`, `tui.ThemeByID`. Module `state` types are module-private. `neovim.New(cfg, themeID, themes, pm, git)` signature matches Task 7's registration. `renode.New(cfg, pm, aur)` matches. +- Known approximations the engineer should verify: + - Task 5 Step 1 asks the engineer to copy Lua heredocs verbatim from `lib/neovim.sh`. Diff the result against the bash file before committing. + - Task 5's `lspconfigBlockRE` regex is a best-effort single-level-brace match. If the NvChad starter's real `init.lua` uses nested tables inside the lspconfig plugin spec, the regex will under-match. Fallback strategy: parse line-by-line, find the line containing `"neovim/nvim-lspconfig"`, walk backward to the opening `{`, walk forward with a brace counter to the matching `},` — adjust during Step 5 if tests fail. + - Task 4's rebuild-after-wizard location needs to match the existing code's variable names for the wizard's return tuple. Read the install command carefully before inserting the rebuild.