diff --git a/docs/superpowers/specs/2026-04-14-plan4a-subcommands-design.md b/docs/superpowers/specs/2026-04-14-plan4a-subcommands-design.md new file mode 100644 index 0000000..9e908ee --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-plan4a-subcommands-design.md @@ -0,0 +1,200 @@ +# 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.