15 KiB
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 exposesList() []Theme,Get(id) (Theme, bool), andApply(cfg, themeID) error. Called from both theinstallcommand (applies the wizard's selected theme at the end of an install run) and the newthemesubcommand.internal/subcommands/newproject/— ownsarchduino new <project> <board>. Board list lives inconfig.Config.Scaffolding.Boards;.inostarter templates embedded.internal/subcommands/doctor/— ownsarchduino doctor. Iterates the module registry, callsDetect+Planon 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; readsassets/*/theme.yaml.
- Exported:
theming_test.goassets/<theme>/theme.yaml— five files (gruvbox,catppuccin,tokyonight,nord,dracula), each containing:display_name: "Gruvbox Dark" nvchad: "gruvbox"assets/<theme>/starship.toml,zellij.kdl,yazi.toml,lazygit.yml— 20 static config files lifted verbatim from the correspondingwrite_*_configheredocs inlib/themes/<theme>.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
- starship.toml →
New — internal/subcommands/newproject/
newproject.gofunc Run(cfg config.Config, project, board string) error.
newproject_test.goassets/<board>.ino— six starter sketches (uno,mega,nano,esp32,esp32s3,esp8266) plusdefault.ino. Content lifted fromlib/scaffold.shheredocs.
New — internal/subcommands/doctor/
doctor.gofunc 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:
(adjust tag syntax — yaml tags per field as with existing types).
type Board struct { Name, FQBN, Template string `yaml:"name,fqbn,template"` } type Scaffolding struct { Boards []Board `yaml:"boards"` } Config.Scaffolding Scaffoldingmergeclause:if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding }.- Defaults: the 6 boards from
lib/scaffold.shFQBN_MAP withtemplateset to the board name (sotemplate: "uno"maps tointernal/subcommands/newproject/assets/uno.ino).
- Add:
-
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.luadoesn't exist.
- Extract the chadrc render+write logic into an exported helper the theming package can call. Suggested signature:
-
internal/tui/state.go- Remove
BuiltinThemes, removeNvChadThemefield fromTheme, removeThemeByID. Wizard takes a[]Themeparameter or reads fromtheming.List()directly. - Option: delete
tui.Themeentirely and have the wizard usetheming.Themedirectly. Weigh the import coupling:internal/tuialready importsinternal/config, andthemingimportsconfigtoo, sotui → themingis fine.
- Remove
-
internal/tui/wizard/wizard.go- Populate the theme dropdown from
theming.List().
- Populate the theme dropdown from
-
cmd/archduino/main.go- Add three
cobra.Commandconstructors:newThemeCmd(),newProjectCmd(),newDoctorCmd(). - Register them on the root command alongside
install. - In the
installRunE, afterengine.Runreturns success, calltheming.Apply(cfg, finalState.ThemeID)and surface any error. buildRegistryno longer needs to receivethemeIDfor neovim's chadrc — neovim's install path writes chadrc based on the ID threaded throughneovim.New, andtheming.Applyoverwrites it afterward. Keeping the parameter is fine; it remains useful for the initial chadrc write.
- Add three
Command surfaces
archduino theme→ interactive huh-based picker listing themes; pre-selectscfg.ThemeOverrides["__active"](or the first theme if unset). On confirm, callstheming.Apply.archduino theme <id>→ non-interactive; callstheming.Apply(cfg, id). Errors if unknown.archduino theme list→ prints<id> — <display_name>one per line to stdout.archduino new <project> <board>→ see data-flow below.archduino doctor→ see data-flow below.
Data Flow
theming.Apply
- Look up theme by ID; return error if unknown (message lists valid IDs).
- For each of the four tool configs under
assets/<theme>/, read viaembed.FSand write to its target path viaplatform.WriteUserFilewith mode 0o644. - If
~/.config/nvim/lua/chadrc.luaexists, callneovim.WriteChadrc(theme.NvChadTheme)to re-render it. If it doesn't exist, skip silently (user hasn't run install yet, or removed nvim config). - Idempotently clear NvChad's theme cache:
os.RemoveAll(~/.cache/nvim/base46)andos.RemoveAll(~/.local/share/nvim/lazy/base46/lua/base46/themes). Errors ignored — cache may not exist. - Update the user's overlay file at
~/.config/archduino/overlay.yaml: load existing YAML (or empty), settheme_overrides.__active: <id>, write back via a YAML round-trip usinggopkg.in/yaml.v3decode-into-map-then-encode. First-run creates the file with the single key.
newproject.Run
- Validate
project: non-empty, no/or\\, doesn't start with.. - Look up board in
cfg.Scaffolding.Boards. If absent, return an error whose message lists all valid board names, one per line. os.Stat(project); if it exists, return "project directory already exists". Do not overwrite.os.MkdirAll(project, 0o755).- Determine
.inoasset path:<board.Template>.ino, falling back todefault.inoif the board-specific asset is missing. Write to<project>/<project>.inoviaplatform.WriteUserFilemode 0o644. - Write
<project>/sketch.yaml:default_fqbn: <FQBN> default_port: auto - Write
<project>/.clangd:CompileFlags: Add: - -xc++ - -std=c++17 - Print a success line to stdout:
Created <project>/ (fqbn: <FQBN>). Next: cd <project> && nvim <project>.ino.
doctor.Run
- For each module in
reg.All(): callDetect(ctx), thenPlan(ctx, state). Format output one row per module:[✓] <id>— empty plan (converged).[✗] <id> (N actions pending)— non-empty plan.[!] <id> — detect failed: <err>— Detect/Plan returned error. Never propagate the error up; record and continue.
- Version drift checks (only if
arduino-cli/wokwi-cliare found on PATH via the injectedRunner):arduino-cli version --format json→ parseVersionString, compare tocfg.ArduinoCLI.Version. Print[✓] arduino-cli <ver>or[!] arduino-cli drift: got <X>, want <Y>.wokwi-cli --version→ parse first line's version-like token, compare tocfg.Renode.WokwiVersion. Same output format.
- Summary line:
<converged>/<total> converged, <pending> need-install, <drift> drift, <errors> errors. - Exit code: 0 if all modules converged AND no drift AND no errors; else 1. Return value from
Runis the exit code; the cobra RunE wrapper callsos.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__activestill 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 viaRunE'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.yamlcontainstheme_overrides.__active: dracula(round-trip throughgopkg.in/yaml.v3).TestApplyPreservesOtherOverlayKeys— seed overlay withextra_packages: [foo], run Apply, assertextra_packagesstill present after write.TestApplyRewritesChadrcIfPresent— seed~/.config/nvim/lua/chadrc.luawith gruvbox; Apply dracula; assert file now containstheme = "dracula".TestApplyNoChadrcIsFine— no chadrc on disk; Apply succeeds.TestApplyUnknownThemeErrors.
newproject_test.go
TestNewCreatesProject— config withunoboard; project dir doesn't exist;Run(cfg, "blinky", "uno")in a tempdir (chdir); assertblinky/blinky.ino+sketch.yaml(containsarduino:avr:uno) +.clangdexist.TestNewRefusesExistingDir— pre-create the target dir;Runreturns error; dir untouched.TestNewFallsBackToDefaultTemplate— config board entry whose.templatepoints at an asset that doesn't exist (use a made-up board withtemplate: "xyz"); Run succeeds and the generated.inomatchesdefault.inocontent.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 forarduino-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
installtests continue to pass; add a unit test that stubstheming.Applyand assertsinstallcalls it after a successfulengine.Runwith 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 --fixmode — reserved for a future plan, possibly merged with Plan 4b's reinstall/reset.- Version-drift remediation — doctor only reports.
archduino newsupport 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 insidePlan. Acceptable fix: factor awriteChadrc(cfg string, nvChadTheme string) errorfree function in the neovim package, have both the Plan action and the newWriteChadrcexport use it. - tui.Theme retirement: wizard test coverage is decent but
BuiltinThemesis referenced ininternal/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; usingmap[string]anydecode/encode will lose both. Acceptable for v1 (the user's overlay is config, not hand-commented prose); document the behavior in thetheming.Applygodoc. - Doctor version parsing:
arduino-cli version --format jsonshape is stable, butwokwi-cli --version's output format is upstream-controlled. A tiny regexv?\d+\.\d+\.\d+covers the common case; if it grows fragile, fall back to byte-for-byte compare withcfg.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:
installpreserves a.bak.<timestamp>copy before the theme apply step. Document clearly in the install command's help text.