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

1515 lines
50 KiB
Markdown

# Plan 4a — User-Facing Subcommands Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship `archduino theme`, `archduino new`, and `archduino doctor` subcommands plus a shared `internal/theming/` package that both the new `theme` subcommand and the existing `install` command use to write starship / zellij / yazi / lazygit / NvChad configs.
**Architecture:** One new package (`theming`) owns per-theme embedded assets + the `Apply` function. Two subcommand packages (`newproject`, `doctor`) implement the remaining two subcommands. The `neovim` module exports a `WriteChadrc` helper that `theming.Apply` reuses to re-render chadrc when the theme changes. `tui.BuiltinThemes` retires; the wizard reads `theming.List()` instead. The `install` command calls `theming.Apply` after a successful `engine.Run` so the selected theme is applied on first run.
**Tech Stack:** Go stdlib + `embed`, `gopkg.in/yaml.v3` (already a dep) for overlay round-trip, `huh` for the interactive theme picker, `text/template` for existing chadrc rendering.
---
## Reference: relevant existing surfaces
```go
// internal/module/module.go — unchanged
type Module interface { Meta() Meta; Detect(ctx) (State, error); Plan(ctx, State) (Plan, error); Apply(ctx, Plan, Reporter) error; Verify(ctx) error }
// internal/platform — unchanged
func WriteUserFile(path string, data []byte, mode os.FileMode) error
type Runner interface { Run(ctx, name string, args ...string) error; Output(ctx, name string, args ...string) ([]byte, error) }
// internal/config/config.go — has ThemeOverrides map[string]string already
type Config struct {
// ... existing fields ...
ThemeOverrides map[string]string
}
const currentVersion = 1
// internal/modules/neovim/neovim.go — private helpers
func (m Module) resolvedTheme() string // returns NvChad theme name
func (m Module) renderChadrc() ([]byte, error) // renders chadrc.lua.tmpl
// internal/tui/state.go — to be retired
type Theme struct { ID, Name, NvChadTheme string }
var BuiltinThemes = []Theme{ ... 5 entries ... }
func ThemeByID(themes []Theme, id string) (Theme, bool)
type WizardState struct { ThemeID string; ... }
// cmd/archduino/main.go
root := &cobra.Command{Use: "archduino"}
root.AddCommand(newInstallCmd())
func newInstallCmd() *cobra.Command
func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error)
```
---
## Task 1: `theming` package skeleton — Theme type, manifest discovery, gruvbox assets
**Files:**
- Create: `internal/theming/theming.go`
- Create: `internal/theming/theming_test.go`
- Create: `internal/theming/assets/gruvbox/theme.yaml`
- Create: `internal/theming/assets/gruvbox/starship.toml`
- Create: `internal/theming/assets/gruvbox/zellij.kdl`
- Create: `internal/theming/assets/gruvbox/yazi.toml`
- Create: `internal/theming/assets/gruvbox/lazygit.yml`
- [ ] **Step 1: Create gruvbox assets**
Extract contents from `/home/marvin/git-clones/ArduinoArchSetup/lib/themes/gruvbox.sh`'s `write_starship_config`, `write_zellij_config`, `write_lazygit_config`, `write_yazi_config` heredocs. Write each heredoc body verbatim to the corresponding asset file (drop the `TOML` / `KDL` / `YAML` end-tokens and the surrounding bash). `theme.yaml`:
```yaml
display_name: "Gruvbox Dark"
nvchad: "gruvbox"
```
- [ ] **Step 2: Write failing test**
```go
package theming
import (
"testing"
)
func TestListDiscoversThemes(t *testing.T) {
themes := List()
if len(themes) == 0 { t.Fatal("no themes discovered") }
for _, th := range themes {
if th.ID == "" || th.DisplayName == "" || th.NvChadTheme == "" {
t.Errorf("incomplete theme: %+v", th)
}
}
}
func TestGetKnownThemeReturnsMatch(t *testing.T) {
got, ok := Get("gruvbox")
if !ok { t.Fatal("gruvbox should exist") }
if got.ID != "gruvbox" || got.DisplayName == "" || got.NvChadTheme != "gruvbox" {
t.Fatalf("got %+v", got)
}
}
func TestGetUnknownThemeReturnsFalse(t *testing.T) {
if _, ok := Get("does-not-exist"); ok {
t.Fatal("bogus id should return ok=false")
}
}
func TestListIsSortedByID(t *testing.T) {
themes := List()
for i := 1; i < len(themes); i++ {
if themes[i].ID < themes[i-1].ID {
t.Fatalf("not sorted: %s before %s", themes[i-1].ID, themes[i].ID)
}
}
}
```
- [ ] **Step 3: Run test, confirm fail**
```
go test ./internal/theming/... -v
```
Expected: `undefined: List` / `undefined: Get`.
- [ ] **Step 4: Implement `theming.go` skeleton**
```go
// Package theming applies a selected colorscheme across starship, zellij,
// yazi, lazygit, and NvChad. Themes are discovered from embedded manifest
// files under assets/<id>/theme.yaml at package init time.
package theming
import (
"embed"
"fmt"
"io/fs"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
//go:embed assets
var assetsFS embed.FS
// Theme describes a colorscheme discovered under assets/<id>/theme.yaml.
type Theme struct {
ID string
DisplayName string
NvChadTheme string
}
type manifest struct {
DisplayName string `yaml:"display_name"`
NvChad string `yaml:"nvchad"`
}
var discovered []Theme
func init() {
themes, err := discover()
if err != nil {
// Package is embed-only; discovery failure is a programmer error,
// not runtime input. Panic so tests surface the mistake early.
panic(fmt.Errorf("theming: discover themes: %w", err))
}
discovered = themes
}
func discover() ([]Theme, error) {
entries, err := fs.ReadDir(assetsFS, "assets")
if err != nil { return nil, err }
var out []Theme
for _, e := range entries {
if !e.IsDir() { continue }
id := e.Name()
m, err := readManifest(id)
if err != nil { return nil, fmt.Errorf("theme %q: %w", id, err) }
out = append(out, Theme{ID: id, DisplayName: m.DisplayName, NvChadTheme: m.NvChad})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
func readManifest(id string) (manifest, error) {
data, err := assetsFS.ReadFile(filepath.Join("assets", id, "theme.yaml"))
if err != nil { return manifest{}, err }
var m manifest
if err := yaml.Unmarshal(data, &m); err != nil { return manifest{}, err }
if m.DisplayName == "" { return manifest{}, fmt.Errorf("empty display_name") }
if m.NvChad == "" { return manifest{}, fmt.Errorf("empty nvchad") }
return m, nil
}
// List returns the themes sorted by ID.
func List() []Theme {
out := make([]Theme, len(discovered))
copy(out, discovered)
return out
}
// Get returns the theme with the given ID, if present.
func Get(id string) (Theme, bool) {
for _, t := range discovered {
if t.ID == id { return t, true }
}
return Theme{}, false
}
// stringsPrefix is a trivial helper reserved for Task 2's tool-config step.
// It exists to keep the strings import live if Task 1's code doesn't use it.
var _ = strings.Split
```
Delete the `_ = strings.Split` line if the build complains; Task 2 will reintroduce `strings` usage.
- [ ] **Step 5: Run tests, confirm pass**
```
go test ./internal/theming/... -v
go test ./...
```
- [ ] **Step 6: Commit**
```bash
git add internal/theming/
git commit -m "feat(theming): add theme discovery with gruvbox assets"
```
---
## Task 2: `theming.Apply` — write the four tool configs
**Files:**
- Modify: `internal/theming/theming.go` — add `Apply(cfg config.Config, themeID string) error`.
- Modify: `internal/theming/theming_test.go` — add tests.
- [ ] **Step 1: Write failing test**
Append to `theming_test.go`:
```go
import (
"os"
"path/filepath"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
)
func TestApplyWritesFourToolConfigs(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := Apply(config.Config{}, "gruvbox"); err != nil { t.Fatal(err) }
checks := []struct{ rel, wantPrefix string }{
{".config/starship.toml", "palette"},
{".config/zellij/config.kdl", "theme"},
{".config/yazi/theme.toml", ""},
{".config/lazygit/config.yml", "gui"},
}
for _, c := range checks {
data, err := os.ReadFile(filepath.Join(home, c.rel))
if err != nil { t.Errorf("%s missing: %v", c.rel, err); continue }
if len(data) == 0 { t.Errorf("%s empty", c.rel) }
if c.wantPrefix != "" && !strings.HasPrefix(strings.TrimSpace(string(data)), c.wantPrefix) {
t.Errorf("%s: want prefix %q, got %q", c.rel, c.wantPrefix, string(data[:min(len(data),40)]))
}
}
}
func TestApplyUnknownThemeErrors(t *testing.T) {
if err := Apply(config.Config{}, "does-not-exist"); err == nil {
t.Fatal("want error on unknown theme")
}
}
```
(The `min` helper is stdlib in Go 1.21+; if the repo's go.mod is older, replace `min(len(data),40)` with a manual min.)
- [ ] **Step 2: Run test, confirm fail**
```
go test ./internal/theming/... -v
```
Expected: `undefined: Apply`.
- [ ] **Step 3: Implement `Apply` (initial version — writes tool configs only, no overlay, no chadrc)**
Add to `theming.go`:
```go
import (
"os"
"path/filepath"
"strings"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
)
// toolConfig describes one tool's config file: its asset name under
// assets/<theme>/ and its user-config destination relative to $HOME.
type toolConfig struct {
Asset string // e.g. "starship.toml"
DestRel string // e.g. ".config/starship.toml"
}
var toolConfigs = []toolConfig{
{"starship.toml", ".config/starship.toml"},
{"zellij.kdl", ".config/zellij/config.kdl"},
{"yazi.toml", ".config/yazi/theme.toml"},
{"lazygit.yml", ".config/lazygit/config.yml"},
}
// Apply writes each tool's theme config to the user's home directory.
// Task 3 adds overlay-file update; Task 4 adds NvChad chadrc rewrite.
func Apply(cfg config.Config, themeID string) error {
th, ok := Get(themeID)
if !ok {
ids := make([]string, 0, len(discovered))
for _, t := range discovered { ids = append(ids, t.ID) }
return fmt.Errorf("unknown theme %q; valid: %s", themeID, strings.Join(ids, ", "))
}
home, err := os.UserHomeDir(); if err != nil { return err }
for _, tc := range toolConfigs {
data, err := assetsFS.ReadFile(filepath.Join("assets", th.ID, tc.Asset))
if err != nil { return fmt.Errorf("read asset %s: %w", tc.Asset, err) }
dst := filepath.Join(home, tc.DestRel)
if err := platform.WriteUserFile(dst, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", tc.DestRel, err)
}
}
_ = cfg // overlay update lands in Task 3
return nil
}
```
Remove the `_ = strings.Split` line from Task 1 now that `strings` is used genuinely.
- [ ] **Step 4: Run tests, confirm pass**
```
go test ./internal/theming/... -v
go test ./...
```
- [ ] **Step 5: Commit**
```bash
git add internal/theming/
git commit -m "feat(theming): apply writes four tool configs to $HOME"
```
---
## Task 3: `theming.Apply` — overlay round-trip
**Files:**
- Modify: `internal/theming/theming.go` — extend `Apply`.
- Modify: `internal/theming/theming_test.go` — add overlay tests.
- [ ] **Step 1: Write failing test**
Append:
```go
func TestApplyUpdatesOverlayActive(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := Apply(config.Config{}, "gruvbox"); err != nil { t.Fatal(err) }
data, err := os.ReadFile(filepath.Join(home, ".config", "archduino", "overlay.yaml"))
if err != nil { t.Fatalf("overlay missing: %v", err) }
if !strings.Contains(string(data), "__active: gruvbox") {
t.Fatalf("overlay missing active theme: %s", data)
}
}
func TestApplyPreservesOtherOverlayKeys(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Seed overlay with a non-theme key.
op := filepath.Join(home, ".config", "archduino", "overlay.yaml")
os.MkdirAll(filepath.Dir(op), 0o755)
os.WriteFile(op, []byte("version: 1\nextra_packages:\n - foo\n"), 0o644)
if err := Apply(config.Config{}, "gruvbox"); err != nil { t.Fatal(err) }
data, _ := os.ReadFile(op)
if !strings.Contains(string(data), "- foo") {
t.Fatalf("extra_packages lost: %s", data)
}
if !strings.Contains(string(data), "__active: gruvbox") {
t.Fatalf("active theme missing after preserve: %s", data)
}
}
```
- [ ] **Step 2: Run tests, confirm fail**
```
go test ./internal/theming/... -run TestApplyUpdates -v
go test ./internal/theming/... -run TestApplyPreserves -v
```
- [ ] **Step 3: Implement the overlay update**
Add a helper and call it at the end of `Apply`:
```go
func updateOverlayActive(themeID string) error {
home, err := os.UserHomeDir(); if err != nil { return err }
path := filepath.Join(home, ".config", "archduino", "overlay.yaml")
// Load existing overlay into a generic map so we preserve sibling keys.
var root map[string]any
if data, err := os.ReadFile(path); err == nil {
if err := yaml.Unmarshal(data, &root); err != nil {
return fmt.Errorf("parse overlay: %w", err)
}
}
if root == nil { root = map[string]any{} }
// Ensure the version stamp is present so Load doesn't complain.
if _, ok := root["version"]; !ok { root["version"] = 1 }
// theme_overrides may be absent, nil, or a map under various yaml decode
// shapes. Normalise to map[string]any.
var overrides map[string]any
if raw, ok := root["theme_overrides"]; ok {
if m, ok := raw.(map[string]any); ok {
overrides = m
}
}
if overrides == nil { overrides = map[string]any{} }
overrides["__active"] = themeID
root["theme_overrides"] = overrides
out, err := yaml.Marshal(root); if err != nil { return err }
return platform.WriteUserFile(path, out, 0o644)
}
```
In `Apply`, after the tool-config loop, add:
```go
if err := updateOverlayActive(themeID); err != nil {
return fmt.Errorf("update overlay: %w", err)
}
```
- [ ] **Step 4: Run tests, confirm pass**
```
go test ./internal/theming/... -v
go test ./...
```
- [ ] **Step 5: Commit**
```bash
git add internal/theming/
git commit -m "feat(theming): apply updates overlay theme_overrides.__active"
```
---
## Task 4: Extract neovim chadrc writer, wire into theming.Apply
**Files:**
- Modify: `internal/modules/neovim/neovim.go` — export `WriteChadrc`.
- Modify: `internal/modules/neovim/neovim_test.go` — add targeted test.
- Modify: `internal/theming/theming.go` — call `neovim.WriteChadrc` from `Apply`.
- Modify: `internal/theming/theming_test.go` — add chadrc tests.
- [ ] **Step 1: Add the exported helper in the neovim package**
In `internal/modules/neovim/neovim.go`, add at file scope (near the other render helpers around line 91):
```go
// WriteChadrc renders chadrc.lua for the given NvChad theme name and writes
// it to ~/.config/nvim/lua/chadrc.lua. If the file does not already exist
// and the surrounding nvim config dir does not exist, the function returns
// nil without writing anything (the user hasn't installed neovim yet).
//
// Exported so the theming package can re-render chadrc on theme switches
// without re-running the full Module pipeline.
func WriteChadrc(nvChadThemeName string) error {
home, err := os.UserHomeDir(); if err != nil { return err }
cfg := filepath.Join(home, ".config", "nvim")
chadrcPath := filepath.Join(cfg, "lua", "chadrc.lua")
// If chadrc doesn't exist, only write when the parent dir exists. A
// fresh system where nvim was never installed should be a no-op.
if _, err := os.Stat(chadrcPath); err != nil {
if _, err := os.Stat(cfg); err != nil {
return nil
}
}
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl"); if err != nil { return err }
tmpl, err := template.New("chadrc").Parse(string(tmplBytes)); if err != nil { return err }
var buf bytes.Buffer
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: nvChadThemeName}); err != nil {
return err
}
return platform.WriteUserFile(chadrcPath, buf.Bytes(), 0o644)
}
```
(This duplicates a few lines from `Module.renderChadrc`; the duplication is acceptable since the renderer is small and the Module-scoped version carries the `Module`'s theme-ID fallback logic that doesn't belong in the standalone helper.)
- [ ] **Step 2: Add a test for the new helper**
In `internal/modules/neovim/neovim_test.go`, add:
```go
func TestWriteChadrcWritesFile(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
cfg := filepath.Join(home, ".config", "nvim")
os.MkdirAll(cfg, 0o755)
if err := WriteChadrc("dracula"); err != nil { t.Fatal(err) }
data, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua"))
if err != nil { t.Fatal(err) }
if !strings.Contains(string(data), `theme = "dracula"`) {
t.Fatalf("theme not applied: %s", data)
}
}
func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
if err := WriteChadrc("gruvbox"); err != nil { t.Fatal(err) }
if _, err := os.Stat(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")); !os.IsNotExist(err) {
t.Fatal("chadrc should not be created on a bare system")
}
}
```
- [ ] **Step 3: Add theming integration**
In `internal/theming/theming.go`, import the neovim package:
```go
import (
// ... existing imports ...
"github.com/MDietermann/ArduinoArchSetup/internal/modules/neovim"
)
```
After the overlay update in `Apply`:
```go
if err := neovim.WriteChadrc(th.NvChadTheme); err != nil {
return fmt.Errorf("update chadrc: %w", err)
}
// Clear NvChad's base46 theme cache so the new theme activates on next
// nvim launch. Errors ignored — the cache may not exist.
_ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46"))
_ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes"))
```
Note: `home` must still be in scope at this point; if the existing code doesn't keep it, hoist `home, _ := os.UserHomeDir()` near the top of `Apply`.
- [ ] **Step 4: Add theming tests**
Append to `theming_test.go`:
```go
func TestApplyRewritesChadrcIfPresent(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
nvimCfg := filepath.Join(home, ".config", "nvim", "lua")
os.MkdirAll(nvimCfg, 0o755)
os.WriteFile(filepath.Join(nvimCfg, "chadrc.lua"),
[]byte(`M.base46 = { theme = "gruvbox" }`), 0o644)
if err := Apply(config.Config{}, "dracula"); err != nil { t.Fatal(err) }
data, _ := os.ReadFile(filepath.Join(nvimCfg, "chadrc.lua"))
if !strings.Contains(string(data), `theme = "dracula"`) {
t.Fatalf("chadrc not rewritten: %s", data)
}
}
func TestApplyNoChadrcIsFine(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
if err := Apply(config.Config{}, "gruvbox"); err != nil { t.Fatal(err) }
}
```
- [ ] **Step 5: Run tests, confirm pass**
```
go test ./internal/theming/... -v
go test ./internal/modules/neovim/... -v
go test ./...
```
- [ ] **Step 6: Commit**
```bash
git add internal/modules/neovim/ internal/theming/
git commit -m "feat(theming): integrate NvChad chadrc rewrite into Apply"
```
---
## Task 5: Add the remaining four themes (catppuccin, tokyonight, nord, dracula)
**Files:**
- Create: `internal/theming/assets/<theme>/{theme.yaml,starship.toml,zellij.kdl,yazi.toml,lazygit.yml}` for four themes.
- [ ] **Step 1: Write failing test**
Append to `theming_test.go`:
```go
func TestAllFiveThemesPresent(t *testing.T) {
want := []string{"catppuccin", "dracula", "gruvbox", "nord", "tokyonight"}
themes := List()
if len(themes) != len(want) {
t.Fatalf("want %d themes, got %d", len(want), len(themes))
}
for i, id := range want {
if themes[i].ID != id { t.Fatalf("themes[%d].ID = %q, want %q", i, themes[i].ID, id) }
}
}
func TestApplyEachThemeWritesStarship(t *testing.T) {
for _, id := range []string{"catppuccin", "dracula", "nord", "tokyonight"} {
id := id
t.Run(id, func(t *testing.T) {
home := t.TempDir(); t.Setenv("HOME", home)
if err := Apply(config.Config{}, id); err != nil { t.Fatal(err) }
data, err := os.ReadFile(filepath.Join(home, ".config", "starship.toml"))
if err != nil { t.Fatal(err) }
if len(data) == 0 { t.Fatal("empty starship.toml") }
})
}
}
```
- [ ] **Step 2: Confirm fail**
```
go test ./internal/theming/... -run TestAllFiveThemesPresent -v
```
Expected: `want 5 themes, got 1`.
- [ ] **Step 3: Copy the four themes' assets**
For each of `catppuccin`, `tokyonight`, `nord`, `dracula`:
- Read `/home/marvin/git-clones/ArduinoArchSetup/lib/themes/<theme>.sh`.
- Create directory `internal/theming/assets/<theme>/`.
- Extract heredoc bodies: `write_starship_config` → `starship.toml`, `write_zellij_config` → `zellij.kdl`, `write_yazi_config` → `yazi.toml`, `write_lazygit_config` → `lazygit.yml`. Drop the heredoc tokens; keep content verbatim.
- Extract the top-of-file variables for the manifest:
```yaml
display_name: "<THEME_DISPLAY_NAME from bash>"
nvchad: "<NVCHAD_THEME from bash>"
```
The bash variables are `THEME_DISPLAY_NAME="..."` and `NVCHAD_THEME="..."` near lines 2-3 of each file.
- [ ] **Step 4: Run tests, confirm pass**
```
go test ./internal/theming/... -v
go test ./...
```
- [ ] **Step 5: Commit**
```bash
git add internal/theming/assets/
git commit -m "feat(theming): add catppuccin, dracula, nord, tokyonight themes"
```
---
## Task 6: Retire `tui.BuiltinThemes`; wizard reads `theming.List()`
**Files:**
- Modify: `internal/tui/state.go` — remove `BuiltinThemes`, `NvChadTheme`, `ThemeByID`; simplify `Theme`.
- Modify: `internal/tui/state_test.go` — adjust or remove affected tests.
- Modify: `internal/tui/wizard/wizard.go` — read from `theming.List()`.
- Modify: `internal/modules/neovim/neovim.go` — drop the `tui.Theme`-based fallback in `resolvedTheme`; switch to `theming.Get`.
- Modify: `cmd/archduino/main.go` — adjust any `tui.BuiltinThemes` references (Plan 3b added one for the pre-wizard `buildRegistry` call — replace with `""` or the first theme from `theming.List()`).
- [ ] **Step 1: Grep current usages**
```
grep -rn "BuiltinThemes\|ThemeByID\|tui.Theme\b" --include="*.go"
```
Expected hits: `internal/tui/state.go`, `internal/tui/state_test.go`, `internal/tui/wizard/wizard.go`, `internal/modules/neovim/neovim.go`, `internal/modules/neovim/neovim_test.go`, `cmd/archduino/main.go`. Note every file before editing.
- [ ] **Step 2: Simplify `tui.Theme`**
In `internal/tui/state.go`:
```go
// Theme is the wizard's display-only view of a colorscheme option.
// Canonical theme metadata lives in internal/theming.
type Theme struct {
ID string
Name string
}
```
Remove `BuiltinThemes`, `ThemeByID`. In `NewWizardState`:
```go
import "github.com/MDietermann/ArduinoArchSetup/internal/theming"
func NewWizardState(cfg config.Config, reg *module.Registry) WizardState {
// ... existing module-choice logic unchanged ...
themes := theming.List()
theme := ""
if len(themes) > 0 { theme = themes[0].ID }
if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" {
theme = override
}
return WizardState{
ThemeID: theme,
Modules: choices,
Parallelism: 1,
}
}
```
- [ ] **Step 3: Rewrite affected tests**
In `internal/tui/state_test.go`, replace any test referencing `BuiltinThemes` or `NvChadTheme` with equivalents against `theming.List()`. Specifically, the `TestBuiltinThemesCarryNvChadName` and `TestThemeByID*` tests (added in Plan 3b Task 2) should be **deleted** — the symbols they test no longer exist in `tui`. Their logic is already covered by `internal/theming`'s own tests (Task 1, Task 5).
A `NewWizardState` test that expected `s.ThemeID == BuiltinThemes[0].ID` should be updated to `s.ThemeID == theming.List()[0].ID`.
- [ ] **Step 4: Update wizard**
In `internal/tui/wizard/wizard.go`:
```go
import "github.com/MDietermann/ArduinoArchSetup/internal/theming"
// ... in Run, replace the themeOptions construction (lines ~26-29):
themes := theming.List()
themeOptions := make([]huh.Option[string], 0, len(themes))
for _, t := range themes {
themeOptions = append(themeOptions, huh.NewOption(t.DisplayName, t.ID))
}
```
Drop `import "github.com/MDietermann/ArduinoArchSetup/internal/tui"` if it's no longer used (the wizard still references `tui.WizardState` and `tui.ModuleChoice`, so keep it).
- [ ] **Step 5: Update neovim module**
In `internal/modules/neovim/neovim.go`, `resolvedTheme` currently takes `[]tui.Theme`. Simplify:
```go
import "github.com/MDietermann/ArduinoArchSetup/internal/theming"
func (m Module) resolvedTheme() string {
if t, ok := theming.Get(m.themeID); ok { return t.NvChadTheme }
if themes := theming.List(); len(themes) > 0 { return themes[0].NvChadTheme }
return "gruvbox"
}
```
Change `New` signature to drop the `themes []tui.Theme` parameter:
```go
func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module {
return Module{cfg: cfg, themeID: themeID, pm: pm, git: git}
}
```
Remove the `themes []tui.Theme` field from `Module` struct.
Update `internal/modules/neovim/neovim_test.go`: replace every `New(cfg, id, tui.BuiltinThemes, pm, git)` call with `New(cfg, id, pm, git)`. Drop the `tui` import if unused.
- [ ] **Step 6: Update cmd/archduino/main.go**
In `buildRegistry`, update the neovim registration:
```go
neovim.New(cfg.Neovim, themeID, pm, git),
```
Replace any `tui.BuiltinThemes[0].ID` reference with `""` (neovim's `resolvedTheme` already falls back to `theming.List()[0].NvChadTheme`).
- [ ] **Step 7: Run the full test suite**
```
go build ./...
go test ./...
```
If tests fail due to leftover `BuiltinThemes` references the grep missed, fix them and rerun.
- [ ] **Step 8: Commit**
```bash
git add internal/tui/ internal/modules/neovim/ cmd/archduino/main.go
git commit -m "refactor: retire tui.BuiltinThemes in favor of theming.List"
```
---
## Task 7: Extend config with `Scaffolding.Boards`
**Files:**
- Modify: `internal/config/config.go`
- Modify: `internal/config/defaults.yaml`
- Modify: `internal/config/config_test.go`
- [ ] **Step 1: Write failing test**
Append to `config_test.go`:
```go
func TestDefaultsCarryScaffoldingBoards(t *testing.T) {
cfg, err := Load("/nonexistent/overlay.yaml")
if err != nil { t.Fatal(err) }
if len(cfg.Scaffolding.Boards) < 6 {
t.Fatalf("want >= 6 boards, got %d", len(cfg.Scaffolding.Boards))
}
want := map[string]string{
"uno": "arduino:avr:uno", "mega": "arduino:avr:mega", "nano": "arduino:avr:nano",
"esp32": "esp32:esp32:esp32", "esp32s3": "esp32:esp32:esp32s3", "esp8266": "esp8266:esp8266:nodemcuv2",
}
got := map[string]string{}
for _, b := range cfg.Scaffolding.Boards { got[b.Name] = b.FQBN }
for name, fqbn := range want {
if got[name] != fqbn { t.Errorf("board %q: got fqbn %q, want %q", name, got[name], fqbn) }
}
}
```
- [ ] **Step 2: Run test, confirm fail**
```
go test ./internal/config/... -run TestDefaultsCarryScaffoldingBoards -v
```
- [ ] **Step 3: Extend `config.go`**
Add types above `Config`:
```go
type Board struct {
Name string `yaml:"name"`
FQBN string `yaml:"fqbn"`
Template string `yaml:"template"`
}
type Scaffolding struct {
Boards []Board `yaml:"boards"`
}
```
Add field to `Config`:
```go
Scaffolding Scaffolding `yaml:"scaffolding"`
```
Extend `merge` with:
```go
if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding }
```
- [ ] **Step 4: Extend `defaults.yaml`**
Append:
```yaml
scaffolding:
boards:
- {name: uno, fqbn: "arduino:avr:uno", template: uno}
- {name: mega, fqbn: "arduino:avr:mega", template: mega}
- {name: nano, fqbn: "arduino:avr:nano", template: nano}
- {name: esp32, fqbn: "esp32:esp32:esp32", template: esp32}
- {name: esp32s3, fqbn: "esp32:esp32:esp32s3", template: esp32s3}
- {name: esp8266, fqbn: "esp8266:esp8266:nodemcuv2", template: esp8266}
```
- [ ] **Step 5: Run tests, confirm pass**
```
go test ./internal/config/... -v
go test ./...
```
- [ ] **Step 6: Commit**
```bash
git add internal/config/
git commit -m "feat(config): add Scaffolding.Boards typed input with 6 defaults"
```
---
## Task 8: `newproject` subcommand package
**Files:**
- Create: `internal/subcommands/newproject/newproject.go`
- Create: `internal/subcommands/newproject/newproject_test.go`
- Create: `internal/subcommands/newproject/assets/default.ino`
- Create: `internal/subcommands/newproject/assets/uno.ino`
- Create: `internal/subcommands/newproject/assets/mega.ino`
- Create: `internal/subcommands/newproject/assets/nano.ino`
- Create: `internal/subcommands/newproject/assets/esp32.ino`
- Create: `internal/subcommands/newproject/assets/esp32s3.ino`
- Create: `internal/subcommands/newproject/assets/esp8266.ino`
- [ ] **Step 1: Create .ino assets**
Read `/home/marvin/git-clones/ArduinoArchSetup/lib/scaffold.sh` and extract the `cat >...` heredoc bodies for each per-board `.ino` template. If any board doesn't have its own heredoc in the bash file, copy the `default.ino` content for it. Keep each file's content verbatim.
For `default.ino`, if the bash has no explicit default heredoc, use this minimal fallback:
```
void setup() {
Serial.begin(115200);
}
void loop() {
}
```
- [ ] **Step 2: Write failing test**
```go
package newproject
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
)
func sampleCfg() config.Config {
return config.Config{
Scaffolding: config.Scaffolding{Boards: []config.Board{
{Name: "uno", FQBN: "arduino:avr:uno", Template: "uno"},
{Name: "xyz", FQBN: "fake:fqbn:xyz", Template: "does-not-exist"},
}},
}
}
func TestRunCreatesProject(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := Run(sampleCfg(), "blinky", "uno"); err != nil { t.Fatal(err) }
ino, err := os.ReadFile(filepath.Join("blinky", "blinky.ino"))
if err != nil { t.Fatalf("blinky.ino missing: %v", err) }
if len(ino) == 0 { t.Error("blinky.ino empty") }
sketch, err := os.ReadFile(filepath.Join("blinky", "sketch.yaml"))
if err != nil { t.Fatalf("sketch.yaml missing: %v", err) }
if !strings.Contains(string(sketch), "arduino:avr:uno") {
t.Errorf("sketch.yaml missing fqbn: %s", sketch)
}
if _, err := os.Stat(filepath.Join("blinky", ".clangd")); err != nil {
t.Errorf(".clangd missing: %v", err)
}
}
func TestRunRefusesExistingDir(t *testing.T) {
dir := t.TempDir(); t.Chdir(dir)
os.Mkdir("blinky", 0o755)
if err := Run(sampleCfg(), "blinky", "uno"); err == nil {
t.Fatal("want error on existing dir")
}
}
func TestRunFallsBackToDefaultTemplate(t *testing.T) {
dir := t.TempDir(); t.Chdir(dir)
if err := Run(sampleCfg(), "thing", "xyz"); err != nil { t.Fatal(err) }
if _, err := os.Stat(filepath.Join("thing", "thing.ino")); err != nil {
t.Fatalf("thing.ino missing: %v", err)
}
}
func TestRunUnknownBoardErrors(t *testing.T) {
dir := t.TempDir(); t.Chdir(dir)
err := Run(sampleCfg(), "thing", "bogus")
if err == nil { t.Fatal("want error") }
if !strings.Contains(err.Error(), "uno") {
t.Errorf("error should list valid boards: %v", err)
}
}
func TestRunRejectsSlashInProjectName(t *testing.T) {
dir := t.TempDir(); t.Chdir(dir)
if err := Run(sampleCfg(), "bad/name", "uno"); err == nil {
t.Fatal("want error on slashes")
}
}
```
`t.Chdir` is Go 1.24+. If the repo's Go is older, replace with an explicit pushd pattern (record `os.Getwd`, `os.Chdir(dir)`, and a `t.Cleanup(func() { os.Chdir(old) })`).
- [ ] **Step 3: Run tests, confirm fail**
```
go test ./internal/subcommands/newproject/... -v
```
Expected: `undefined: Run`.
- [ ] **Step 4: Implement `newproject.go`**
```go
// Package newproject scaffolds a new Arduino sketch directory.
package newproject
import (
"embed"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
)
//go:embed assets
var assetsFS embed.FS
const clangdContent = `CompileFlags:
Add:
- -xc++
- -std=c++17
`
// Run creates a new Arduino project directory. It errors if the project
// directory already exists, if the board is unknown, or if the project name
// contains a path separator.
func Run(cfg config.Config, project, board string) error {
if project == "" {
return fmt.Errorf("project name required")
}
if strings.ContainsAny(project, "/\\") || strings.HasPrefix(project, ".") {
return fmt.Errorf("invalid project name %q", project)
}
var b *config.Board
var valid []string
for i := range cfg.Scaffolding.Boards {
bp := &cfg.Scaffolding.Boards[i]
valid = append(valid, bp.Name)
if bp.Name == board { b = bp }
}
if b == nil {
return fmt.Errorf("unknown board %q; valid: %s", board, strings.Join(valid, ", "))
}
if _, err := os.Stat(project); err == nil {
return fmt.Errorf("project directory %q already exists", project)
}
if err := os.MkdirAll(project, 0o755); err != nil { return err }
// Pick .ino template: try <template>.ino, else default.ino.
inoData, err := assetsFS.ReadFile(filepath.Join("assets", b.Template+".ino"))
if err != nil {
inoData, err = assetsFS.ReadFile("assets/default.ino")
if err != nil { return err }
}
if err := platform.WriteUserFile(filepath.Join(project, project+".ino"), inoData, 0o644); err != nil {
return err
}
sketch := fmt.Sprintf("default_fqbn: %s\ndefault_port: auto\n", b.FQBN)
if err := platform.WriteUserFile(filepath.Join(project, "sketch.yaml"), []byte(sketch), 0o644); err != nil {
return err
}
if err := platform.WriteUserFile(filepath.Join(project, ".clangd"), []byte(clangdContent), 0o644); err != nil {
return err
}
return nil
}
```
- [ ] **Step 5: Run tests, confirm pass**
```
go test ./internal/subcommands/newproject/... -v
go test ./...
```
- [ ] **Step 6: Commit**
```bash
git add internal/subcommands/newproject/
git commit -m "feat(newproject): add archduino new project scaffolder"
```
---
## Task 9: `doctor` subcommand package
**Files:**
- Create: `internal/subcommands/doctor/doctor.go`
- Create: `internal/subcommands/doctor/doctor_test.go`
- [ ] **Step 1: Write failing test**
```go
package doctor
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module"
)
// convergedModule returns an empty Plan — always converged.
type convergedModule struct{ id string }
func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} }
func (c convergedModule) Detect(context.Context) (module.State, error) { return nil, nil }
func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) { return module.Plan{}, nil }
func (c convergedModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (c convergedModule) Verify(context.Context) error { return nil }
type pendingModule struct{ id string }
func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} }
func (p pendingModule) Detect(context.Context) (module.State, error) { return nil, nil }
func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) {
return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil
}
func (p pendingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (p pendingModule) Verify(context.Context) error { return nil }
type detectFailModule struct{ id string }
func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} }
func (d detectFailModule) Detect(context.Context) (module.State, error) { return nil, errors.New("boom") }
func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) { return module.Plan{}, nil }
func (d detectFailModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (d detectFailModule) Verify(context.Context) error { return nil }
// notFoundRunner simulates "command not found" for version checks.
type notFoundRunner struct{}
func (notFoundRunner) Run(context.Context, string, ...string) error { return errors.New("not found") }
func (notFoundRunner) Output(context.Context, string, ...string) ([]byte, error) { return nil, errors.New("not found") }
// fixedRunner returns a preset version string for a matching command.
type fixedRunner struct {
arduinoOut []byte
arduinoErr error
wokwiOut []byte
wokwiErr error
}
func (f fixedRunner) Run(context.Context, string, ...string) error { return nil }
func (f fixedRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
switch name {
case "arduino-cli":
return f.arduinoOut, f.arduinoErr
case "wokwi-cli":
return f.wokwiOut, f.wokwiErr
}
return nil, errors.New("no match")
}
func TestDoctorAllConvergedExitsZero(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(convergedModule{id: "foo"})
var buf bytes.Buffer
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
if code != 0 { t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String()) }
if !strings.Contains(buf.String(), "[✓] foo") {
t.Errorf("missing converged mark: %s", buf.String())
}
}
func TestDoctorDivergedExitsOne(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(pendingModule{id: "bar"})
var buf bytes.Buffer
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
if code != 1 { t.Fatalf("exit %d, want 1", code) }
if !strings.Contains(buf.String(), "[✗] bar") {
t.Errorf("missing diverged mark: %s", buf.String())
}
}
func TestDoctorDetectErrorIsReported(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(detectFailModule{id: "baz"})
var buf bytes.Buffer
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
if code != 1 { t.Fatalf("exit %d, want 1", code) }
if !strings.Contains(buf.String(), "[!] baz") {
t.Errorf("missing error mark: %s", buf.String())
}
}
func TestDoctorArduinoCLIDriftIsReported(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(convergedModule{id: "foo"})
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
runner := fixedRunner{arduinoOut: []byte(`{"VersionString":"0.36.0"}`)}
var buf bytes.Buffer
code := Run(context.Background(), cfg, reg, runner, &buf)
if code != 1 { t.Fatalf("exit %d, want 1 (drift)", code) }
if !strings.Contains(buf.String(), "drift") {
t.Errorf("missing drift report: %s", buf.String())
}
}
func TestDoctorMissingBinariesSkipVersionCheck(t *testing.T) {
reg := module.NewRegistry()
_ = reg.Register(convergedModule{id: "foo"})
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
var buf bytes.Buffer
code := Run(context.Background(), cfg, reg, notFoundRunner{}, &buf)
if code != 0 { t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String()) }
}
```
- [ ] **Step 2: Run tests, confirm fail**
```
go test ./internal/subcommands/doctor/... -v
```
Expected: `undefined: Run`.
- [ ] **Step 3: Implement `doctor.go`**
```go
// Package doctor reports module convergence and pinned-tool version drift.
package doctor
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
)
// Run inspects every module in the registry plus pinned tool versions and
// writes a human-readable report to w. Returns 0 when everything is
// converged with no drift; 1 otherwise. Never panics on module errors.
func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) int {
var converged, pending, errs, drift int
for _, m := range reg.All() {
id := m.Meta().ID
st, err := m.Detect(ctx)
if err != nil {
fmt.Fprintf(w, "[!] %s — detect failed: %v\n", id, err)
errs++
continue
}
pl, err := m.Plan(ctx, st)
if err != nil {
fmt.Fprintf(w, "[!] %s — plan failed: %v\n", id, err)
errs++
continue
}
if pl.IsEmpty() {
fmt.Fprintf(w, "[✓] %s\n", id)
converged++
} else {
fmt.Fprintf(w, "[✗] %s (%d actions pending)\n", id, len(pl.Actions))
pending++
}
}
drift += checkArduinoCLI(ctx, runner, cfg.ArduinoCLI.Version, w)
drift += checkWokwiCLI(ctx, runner, cfg.Renode.WokwiVersion, w)
fmt.Fprintf(w, "\n%d converged, %d need-install, %d drift, %d errors\n", converged, pending, drift, errs)
if pending > 0 || drift > 0 || errs > 0 { return 1 }
return 0
}
type arduinoCLIVersion struct {
VersionString string `json:"VersionString"`
}
func checkArduinoCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
if want == "" { return 0 }
out, err := runner.Output(ctx, "arduino-cli", "version", "--format", "json")
if err != nil { return 0 } // not installed → silent skip
var v arduinoCLIVersion
if err := json.Unmarshal(out, &v); err != nil || v.VersionString == "" {
fmt.Fprintf(w, "[!] arduino-cli — version parse failed\n")
return 1
}
if v.VersionString != want {
fmt.Fprintf(w, "[!] arduino-cli drift: got %s, want %s\n", v.VersionString, want)
return 1
}
fmt.Fprintf(w, "[✓] arduino-cli %s\n", v.VersionString)
return 0
}
var wokwiVersionRE = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
func checkWokwiCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
if want == "" { return 0 }
out, err := runner.Output(ctx, "wokwi-cli", "--version")
if err != nil { return 0 }
m := wokwiVersionRE.FindStringSubmatch(strings.TrimSpace(string(out)))
if len(m) < 2 {
fmt.Fprintf(w, "[!] wokwi-cli — version parse failed\n")
return 1
}
got := m[1]
if got != want {
fmt.Fprintf(w, "[!] wokwi-cli drift: got %s, want %s\n", got, want)
return 1
}
fmt.Fprintf(w, "[✓] wokwi-cli %s\n", got)
return 0
}
```
- [ ] **Step 4: Run tests, confirm pass**
```
go test ./internal/subcommands/doctor/... -v
go test ./...
```
- [ ] **Step 5: Commit**
```bash
git add internal/subcommands/doctor/
git commit -m "feat(doctor): add archduino doctor subcommand"
```
---
## Task 10: Wire all three subcommands into cobra + integrate theming into install
**Files:**
- Modify: `cmd/archduino/main.go`
- [ ] **Step 1: Add the three cobra constructors**
Append near the existing `newInstallCmd`:
```go
func newThemeCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "theme [id]",
Short: "Switch the active colorscheme across tools",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil { return err }
// "list" short-circuit.
if len(args) == 1 && args[0] == "list" {
for _, t := range theming.List() {
fmt.Printf("%s — %s\n", t.ID, t.DisplayName)
}
return nil
}
var id string
if len(args) == 1 {
id = args[0]
} else {
// Interactive picker.
themes := theming.List()
opts := make([]huh.Option[string], 0, len(themes))
for _, t := range themes { opts = append(opts, huh.NewOption(t.DisplayName, t.ID)) }
id = cfg.ThemeOverrides["__active"]
if id == "" && len(themes) > 0 { id = themes[0].ID }
form := huh.NewForm(huh.NewGroup(
huh.NewSelect[string]().Title("Choose a theme").Options(opts...).Value(&id),
))
if err := form.RunWithContext(cmd.Context()); err != nil {
if errors.Is(err, huh.ErrUserAborted) { return nil }
return err
}
}
if err := theming.Apply(cfg, id); err != nil { return err }
fmt.Printf("Applied theme: %s\n", id)
fmt.Println("Restart zellij and neovim to see the new colors.")
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml (defaults to ~/.config/archduino/overlay.yaml)")
return cmd
}
func newNewCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "new <project> <board>",
Short: "Scaffold a new Arduino sketch directory",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil { return err }
project, board := args[0], args[1]
if err := newproject.Run(cfg, project, board); err != nil { return err }
fmt.Printf("Created %s/ (board: %s)\n", project, board)
fmt.Printf("Next: cd %s && nvim %s.ino\n", project, project)
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
return cmd
}
func newDoctorCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "doctor",
Short: "Report module convergence and pinned-tool version drift",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil { return err }
reg, err := buildRegistry(cfg, cfg.ThemeOverrides["__active"])
if err != nil { return fmt.Errorf("build registry: %w", err) }
code := doctor.Run(cmd.Context(), cfg, reg, platform.ExecRunner{}, os.Stdout)
if code != 0 { os.Exit(code) }
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
return cmd
}
```
Add these registrations near `root.AddCommand(newInstallCmd())`:
```go
root.AddCommand(newThemeCmd())
root.AddCommand(newNewCmd())
root.AddCommand(newDoctorCmd())
```
Add imports:
```go
"errors"
"github.com/charmbracelet/huh"
"github.com/MDietermann/ArduinoArchSetup/internal/subcommands/doctor"
"github.com/MDietermann/ArduinoArchSetup/internal/subcommands/newproject"
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
```
- [ ] **Step 2: Integrate theming into install**
In `newInstallCmd`'s RunE, after the block that invokes `engine.Run` and assigns `sum, runErr` (the existing plain-path or TUI-path block), add — after any error handling that returns early and after the TUI post-wizard logic that rebuilds the registry:
```go
// Apply the wizard-selected theme (no-op if user chose a theme
// whose configs are already on disk).
if runErr == nil && !dryRun {
selectedTheme := finalState.ThemeID
if selectedTheme == "" { selectedTheme = cfg.ThemeOverrides["__active"] }
if selectedTheme != "" {
if err := theming.Apply(cfg, selectedTheme); err != nil {
fmt.Fprintf(os.Stderr, "warning: theme apply failed: %v\n", err)
}
}
}
```
The exact variable names (`finalState`, `runErr`, `dryRun`) come from the existing install command code. Match them to what's currently in `newInstallCmd`. If the plain-output path doesn't have access to a chosen theme ID (because no wizard ran), fall through with just `cfg.ThemeOverrides["__active"]`.
- [ ] **Step 3: Run build + tests**
```
go build ./...
go test ./...
```
Both green.
- [ ] **Step 4: Manual smoke (optional, non-blocking)**
```
go run ./cmd/archduino --help
go run ./cmd/archduino theme list
go run ./cmd/archduino doctor
```
Expected: help lists all four subcommands; `theme list` prints 5 themes; `doctor` reports per-module status.
- [ ] **Step 5: Commit**
```bash
git add cmd/archduino/main.go
git commit -m "feat(cmd): add theme/new/doctor subcommands; install applies theme"
```
---
## Self-review notes
- Spec coverage: theming package (Tasks 1-5), tui retirement (Task 6), config extension (Task 7), newproject (Task 8), doctor (Task 9), cobra wiring + install integration (Task 10). All spec sections mapped.
- Type consistency: `theming.Theme{ID, DisplayName, NvChadTheme}`, `theming.List`, `theming.Get`, `theming.Apply(cfg, id)`, `config.Board{Name, FQBN, Template}`, `config.Scaffolding.Boards`, `newproject.Run(cfg, project, board) error`, `doctor.Run(ctx, cfg, reg, runner, w) int`, `neovim.WriteChadrc(nvChadThemeName) error`. Neovim `New` signature change in Task 6 consistently applied to `main.go` and tests.
- Known approximations:
- Task 1's `_ = strings.Split` is a placeholder to keep the import live for Task 2; delete if the build complains.
- Task 5 Step 3 requires copy-paste from 4 bash files. Diff carefully — bash `$variable` interpolations are rare in the theme files but not impossible; any `$foo` in the heredoc bodies must be preserved as literal text (the heredocs use quoted delimiters `'TOML'` / `'KDL'` / `'YAML'` which disable expansion in bash).
- Task 6 is the highest-risk task because it touches six files. The grep in Step 1 is authoritative; do not skip it.
- Task 10 Step 2's `finalState.ThemeID` depends on the current install command's variable name. If it's a different name (e.g., `state`), use that. Read the current code before inserting.