# Plan 4a — User-Facing Subcommands Design **Status:** Approved 2026-04-14 **Scope:** Ship three new subcommands — `archduino theme`, `archduino new`, `archduino doctor` — plus the shared `theming` package they all build on. This plan absorbs the functionality of `lib/scaffold.sh` (originally deferred from Plan 3b) and `lib/themes/*.sh` natively into Go, without shipping bash wrappers. Plan 4b (reinstall/reset mode), 4c (bootstrap + goreleaser), and 4d (CI) are separate plans with independent specs. ## Goal Complete user-facing feature parity with the Bash installer by adding three subcommands: a theme switcher that updates starship/zellij/yazi/lazygit/NvChad configs in place; a project scaffolder for Arduino sketches; and a doctor command that reports module convergence and pinned-tool version drift. ## Architecture One new shared package and two subcommand packages: - **`internal/theming/`** — reusable package that embeds per-theme config assets (5 themes × 4 tool configs + a manifest each) and exposes `List() []Theme`, `Get(id) (Theme, bool)`, and `Apply(cfg, themeID) error`. Called from both the `install` command (applies the wizard's selected theme at the end of an install run) and the new `theme` subcommand. - **`internal/subcommands/newproject/`** — owns `archduino new `. Board list lives in `config.Config.Scaffolding.Boards`; `.ino` starter templates embedded. - **`internal/subcommands/doctor/`** — owns `archduino doctor`. Iterates the module registry, calls `Detect` + `Plan` on each, reports per-module status and pinned-tool version drift. `tui.BuiltinThemes` and the `tui.Theme.NvChadTheme` field retire: the canonical theme list is now discovered from `theming`'s embedded manifests. The wizard calls `theming.List()`. `tui.Theme` stays as a thin display value (`ID`, `Name`) used by the wizard's dropdown. The existing `install` command gains a final step: after `engine.Run` succeeds, call `theming.Apply(cfg, finalState.ThemeID)` so the initial install leaves tools themed, not just installed. This is an improvement over the Bash behavior, where the theme was never applied during install. ## Tech Stack Go stdlib + existing deps. `go:embed` for all assets. `gopkg.in/yaml.v3` for overlay round-trip updates. `huh` (already a dep) for the interactive theme picker. `text/template` used only where needed (neovim chadrc already uses it; theming's tool configs are static and don't). ## Files ### New — `internal/theming/` - `theming.go` - Exported: `type Theme struct { ID, DisplayName, NvChadTheme string }` - `func List() []Theme` — returns themes in manifest-discovery order (sort by ID for determinism). - `func Get(id string) (Theme, bool)` - `func Apply(cfg config.Config, themeID string) error` - Private: `discoverThemes()` run once at package init; reads `assets/*/theme.yaml`. - `theming_test.go` - `assets//theme.yaml` — five files (`gruvbox`, `catppuccin`, `tokyonight`, `nord`, `dracula`), each containing: ```yaml display_name: "Gruvbox Dark" nvchad: "gruvbox" ``` - `assets//starship.toml`, `zellij.kdl`, `yazi.toml`, `lazygit.yml` — 20 static config files lifted verbatim from the corresponding `write_*_config` heredocs in `lib/themes/.sh`. Tool config paths on disk are: - starship.toml → `~/.config/starship.toml` - zellij.kdl → `~/.config/zellij/config.kdl` - yazi.toml → `~/.config/yazi/theme.toml` - lazygit.yml → `~/.config/lazygit/config.yml` ### New — `internal/subcommands/newproject/` - `newproject.go` - `func Run(cfg config.Config, project, board string) error`. - `newproject_test.go` - `assets/.ino` — six starter sketches (`uno`, `mega`, `nano`, `esp32`, `esp32s3`, `esp8266`) plus `default.ino`. Content lifted from `lib/scaffold.sh` heredocs. ### New — `internal/subcommands/doctor/` - `doctor.go` - `func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) (exitCode int)` - `doctor_test.go` ### Modified - `internal/config/config.go` + `internal/config/defaults.yaml` - Add: ```go type Board struct { Name, FQBN, Template string `yaml:"name,fqbn,template"` } type Scaffolding struct { Boards []Board `yaml:"boards"` } ``` (adjust tag syntax — yaml tags per field as with existing types). - `Config.Scaffolding Scaffolding` - `merge` clause: `if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding }`. - Defaults: the 6 boards from `lib/scaffold.sh` FQBN_MAP with `template` set to the board name (so `template: "uno"` maps to `internal/subcommands/newproject/assets/uno.ino`). - `internal/modules/neovim/neovim.go` - Extract the chadrc render+write logic into an exported helper the theming package can call. Suggested signature: `func RewriteChadrc(themeID string, themes []Theme) error` (takes themes so the module doesn't depend on the theming package — avoid import cycle). Or simpler: `func WriteChadrc(nvChadThemeName string) error` — takes the already-resolved NvChad name, no theme-registry knowledge needed. The helper no-ops if `~/.config/nvim/lua/chadrc.lua` doesn't exist. - `internal/tui/state.go` - Remove `BuiltinThemes`, remove `NvChadTheme` field from `Theme`, remove `ThemeByID`. Wizard takes a `[]Theme` parameter or reads from `theming.List()` directly. - Option: delete `tui.Theme` entirely and have the wizard use `theming.Theme` directly. Weigh the import coupling: `internal/tui` already imports `internal/config`, and `theming` imports `config` too, so `tui → theming` is fine. - `internal/tui/wizard/wizard.go` - Populate the theme dropdown from `theming.List()`. - `cmd/archduino/main.go` - Add three `cobra.Command` constructors: `newThemeCmd()`, `newProjectCmd()`, `newDoctorCmd()`. - Register them on the root command alongside `install`. - In the `install` RunE, after `engine.Run` returns success, call `theming.Apply(cfg, finalState.ThemeID)` and surface any error. - `buildRegistry` no longer needs to receive `themeID` for neovim's chadrc — neovim's install path writes chadrc based on the ID threaded through `neovim.New`, and `theming.Apply` overwrites it afterward. Keeping the parameter is fine; it remains useful for the initial chadrc write. ## Command surfaces - `archduino theme` → interactive huh-based picker listing themes; pre-selects `cfg.ThemeOverrides["__active"]` (or the first theme if unset). On confirm, calls `theming.Apply`. - `archduino theme ` → non-interactive; calls `theming.Apply(cfg, id)`. Errors if unknown. - `archduino theme list` → prints ` — ` one per line to stdout. - `archduino new ` → see data-flow below. - `archduino doctor` → see data-flow below. ## Data Flow ### theming.Apply 1. Look up theme by ID; return error if unknown (message lists valid IDs). 2. For each of the four tool configs under `assets//`, read via `embed.FS` and write to its target path via `platform.WriteUserFile` with mode 0o644. 3. If `~/.config/nvim/lua/chadrc.lua` exists, call `neovim.WriteChadrc(theme.NvChadTheme)` to re-render it. If it doesn't exist, skip silently (user hasn't run install yet, or removed nvim config). 4. Idempotently clear NvChad's theme cache: `os.RemoveAll(~/.cache/nvim/base46)` and `os.RemoveAll(~/.local/share/nvim/lazy/base46/lua/base46/themes)`. Errors ignored — cache may not exist. 5. Update the user's overlay file at `~/.config/archduino/overlay.yaml`: load existing YAML (or empty), set `theme_overrides.__active: `, write back via a YAML round-trip using `gopkg.in/yaml.v3` decode-into-map-then-encode. First-run creates the file with the single key. ### newproject.Run 1. Validate `project`: non-empty, no `/` or `\\`, doesn't start with `.`. 2. Look up board in `cfg.Scaffolding.Boards`. If absent, return an error whose message lists all valid board names, one per line. 3. `os.Stat(project)`; if it exists, return "project directory already exists". Do not overwrite. 4. `os.MkdirAll(project, 0o755)`. 5. Determine `.ino` asset path: `.ino`, falling back to `default.ino` if the board-specific asset is missing. Write to `/.ino` via `platform.WriteUserFile` mode 0o644. 6. Write `/sketch.yaml`: ```yaml default_fqbn: default_port: auto ``` 7. Write `/.clangd`: ```yaml CompileFlags: Add: - -xc++ - -std=c++17 ``` 8. Print a success line to stdout: `Created / (fqbn: ). Next: cd && nvim .ino`. ### doctor.Run 1. For each module in `reg.All()`: call `Detect(ctx)`, then `Plan(ctx, state)`. Format output one row per module: - `[✓] ` — empty plan (converged). - `[✗] (N actions pending)` — non-empty plan. - `[!] — detect failed: ` — Detect/Plan returned error. Never propagate the error up; record and continue. 2. Version drift checks (only if `arduino-cli` / `wokwi-cli` are found on PATH via the injected `Runner`): - `arduino-cli version --format json` → parse `VersionString`, compare to `cfg.ArduinoCLI.Version`. Print `[✓] arduino-cli ` or `[!] arduino-cli drift: got , want `. - `wokwi-cli --version` → parse first line's version-like token, compare to `cfg.Renode.WokwiVersion`. Same output format. 3. Summary line: `/ converged, need-install, drift, errors`. 4. Exit code: 0 if all modules converged AND no drift AND no errors; else 1. Return value from `Run` is the exit code; the cobra RunE wrapper calls `os.Exit(code)`. No network calls. No writes. Fast enough to run interactively (<5s on a typical install). ## Error Handling - `theming.Apply`: first write error aborts. No rollback. User re-runs after fixing (partial state is a benign overlap of two themes' configs on disk until re-run). Overlay update happens last; if a config write fails, the overlay is not touched, so `__active` still reflects the previously-applied theme. - `newproject.Run`: refuses to overwrite an existing project directory. All other failures (mkdir, write) bubble up to the cobra layer and print via `RunE`'s default error handling. - `doctor.Run`: never returns a Go error. Module failures are reported inline; only a programmer error (e.g., nil registry) would panic. ## Testing ### theming_test.go (all unit, tempdir-based $HOME) - `TestListReadsManifests` — asserts 5 themes discovered, each with non-empty DisplayName and NvChadTheme. - `TestGetKnownAndUnknown` — happy path + false return for bogus ID. - `TestApplyWritesAllFourConfigs` — `t.Setenv("HOME", …)`; `Apply(cfg, "dracula")`; assert each of the 4 target files exists with non-empty content and the first line matches the asset's first line (robust to copy-paste drift in tests). - `TestApplyUpdatesOverlayActive` — after Apply, `~/.config/archduino/overlay.yaml` contains `theme_overrides.__active: dracula` (round-trip through `gopkg.in/yaml.v3`). - `TestApplyPreservesOtherOverlayKeys` — seed overlay with `extra_packages: [foo]`, run Apply, assert `extra_packages` still present after write. - `TestApplyRewritesChadrcIfPresent` — seed `~/.config/nvim/lua/chadrc.lua` with gruvbox; Apply dracula; assert file now contains `theme = "dracula"`. - `TestApplyNoChadrcIsFine` — no chadrc on disk; Apply succeeds. - `TestApplyUnknownThemeErrors`. ### newproject_test.go - `TestNewCreatesProject` — config with `uno` board; project dir doesn't exist; `Run(cfg, "blinky", "uno")` in a tempdir (chdir); assert `blinky/blinky.ino` + `sketch.yaml` (contains `arduino:avr:uno`) + `.clangd` exist. - `TestNewRefusesExistingDir` — pre-create the target dir; `Run` returns error; dir untouched. - `TestNewFallsBackToDefaultTemplate` — config board entry whose `.template` points at an asset that doesn't exist (use a made-up board with `template: "xyz"`); Run succeeds and the generated `.ino` matches `default.ino` content. - `TestNewUnknownBoardErrors` — error message lists all valid board names. - `TestNewRejectsSlashInProjectName`. ### doctor_test.go - `TestDoctorAllConvergedExitsZero` — single stub module returning empty Plan; runner that makes version commands not-found (`exec: "arduino-cli": executable file not found in $PATH`-style error — skip version checks cleanly). Exit code 0. - `TestDoctorDivergedExitsOne` — single stub module returning a Plan with actions; exit code 1. - `TestDoctorDetectErrorIsReported` — module whose Detect returns an error; doctor prints `[!]` and exits 1. - `TestDoctorArduinoCLIDriftIsReported` — injected Runner returns a different version for `arduino-cli version --format json`; exit code 1; output contains "drift". - `TestDoctorMissingBinariesSkipVersionCheck` — runner returns "not found"; no false drift report; exit reflects module status only. ### install-command integration (smoke-level) - Existing `install` tests continue to pass; add a unit test that stubs `theming.Apply` and asserts `install` calls it after a successful `engine.Run` with the wizard's ThemeID. No integration tests against real starship/zellij/arduino-cli/wokwi binaries — Plan 4d (CI) introduces those. ## Non-Goals - Per-user theme creation / custom palettes — pinned to the five built-in themes. - Live reload: users must restart their shell, zellij session, etc. manually (bash also required this). - `archduino doctor --fix` mode — reserved for a future plan, possibly merged with Plan 4b's reinstall/reset. - Version-drift remediation — doctor only reports. - `archduino new` support for non-Arduino-CLI ecosystems (PlatformIO, etc.). ## Open Risks / Watchpoints - **NvChad chadrc extraction:** pulling the render-and-write out of `neovim.Module.Plan`'s closures into a free function requires care — currently that closure is built inside `Plan`. Acceptable fix: factor a `writeChadrc(cfg string, nvChadTheme string) error` free function in the neovim package, have both the Plan action and the new `WriteChadrc` export use it. - **tui.Theme retirement:** wizard test coverage is decent but `BuiltinThemes` is referenced in `internal/tui/state_test.go`; removing the symbol requires updating those tests. Grep before deleting. - **Overlay YAML round-trip:** `gopkg.in/yaml.v3`'s node-based decoder preserves comments and order; using `map[string]any` decode/encode will lose both. Acceptable for v1 (the user's overlay is config, not hand-commented prose); document the behavior in the `theming.Apply` godoc. - **Doctor version parsing:** `arduino-cli version --format json` shape is stable, but `wokwi-cli --version`'s output format is upstream-controlled. A tiny regex `v?\d+\.\d+\.\d+` covers the common case; if it grows fragile, fall back to byte-for-byte compare with `cfg.Renode.WokwiVersion`. - **Theme-applied-during-install is a behavior change** vs. Bash. It means a fresh install writes tool configs. If the user had pre-existing starship/zellij configs they liked, they get overwritten. Mitigation: `install` preserves a `.bak.` copy before the theme apply step. Document clearly in the install command's help text.