Files
Archduino/docs/superpowers/plans/2026-04-14-plan3b-install-modules.md

46 KiB

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

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

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

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

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:

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

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:

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:

    Neovim          NeovimConfig       `yaml:"neovim"`
    Renode          RenodeConfig       `yaml:"renode"`

Extend merge with:

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


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

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:

reg, err := buildRegistry(cfg)

to:

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:

// 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
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 <leader>a* and <leader>d* bindings).

(Substitute the exact bash-heredoc contents — do not paraphrase.)

  • Step 2: Write the failing test
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
// 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
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 <firmware.elf> [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" <<EOF
using sysbus

mach create "$MACHINE"
machine LoadPlatformDescription @$RENODE_PLATFORM

sysbus LoadELF @$(realpath "$ELF")

machine StartGdbServer 3333

showAnalyzer sysbus.uart0 Antmicro.Renode.Analyzers.LoggingUartAnalyzer

start
EOF

echo "Starting Renode ($MACHINE) with GDB server on :3333"
echo "Connect debugger:  nvim → <leader>ds  or  gdb → target remote :3333"
echo ""

renode --disable-xwt "$RESC"
rm -f "$RESC"
  • Step 2: Write the failing test
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
// 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
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:

        neovim.New(cfg.Neovim, themeID, tui.BuiltinThemes, pm, git),
        renode.New(cfg.Renode, pm, yay),

Add imports at the top:

    "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
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.