docs(specs): add Plan 4a user-facing subcommands design
This commit is contained in:
200
docs/superpowers/specs/2026-04-14-plan4a-subcommands-design.md
Normal file
200
docs/superpowers/specs/2026-04-14-plan4a-subcommands-design.md
Normal file
@@ -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 <project> <board>`. 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>/theme.yaml` — five files (`gruvbox`, `catppuccin`, `tokyonight`, `nord`, `dracula`), each containing:
|
||||||
|
```yaml
|
||||||
|
display_name: "Gruvbox Dark"
|
||||||
|
nvchad: "gruvbox"
|
||||||
|
```
|
||||||
|
- `assets/<theme>/starship.toml`, `zellij.kdl`, `yazi.toml`, `lazygit.yml` — 20 static config files lifted verbatim from the corresponding `write_*_config` heredocs in `lib/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`
|
||||||
|
|
||||||
|
### New — `internal/subcommands/newproject/`
|
||||||
|
|
||||||
|
- `newproject.go`
|
||||||
|
- `func Run(cfg config.Config, project, board string) error`.
|
||||||
|
- `newproject_test.go`
|
||||||
|
- `assets/<board>.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 <id>` → non-interactive; calls `theming.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
|
||||||
|
|
||||||
|
1. Look up theme by ID; return error if unknown (message lists valid IDs).
|
||||||
|
2. For each of the four tool configs under `assets/<theme>/`, 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: <id>`, 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: `<board.Template>.ino`, falling back to `default.ino` if the board-specific asset is missing. Write to `<project>/<project>.ino` via `platform.WriteUserFile` mode 0o644.
|
||||||
|
6. Write `<project>/sketch.yaml`:
|
||||||
|
```yaml
|
||||||
|
default_fqbn: <FQBN>
|
||||||
|
default_port: auto
|
||||||
|
```
|
||||||
|
7. Write `<project>/.clangd`:
|
||||||
|
```yaml
|
||||||
|
CompileFlags:
|
||||||
|
Add:
|
||||||
|
- -xc++
|
||||||
|
- -std=c++17
|
||||||
|
```
|
||||||
|
8. Print a success line to stdout: `Created <project>/ (fqbn: <FQBN>). Next: cd <project> && nvim <project>.ino`.
|
||||||
|
|
||||||
|
### doctor.Run
|
||||||
|
|
||||||
|
1. For each module in `reg.All()`: call `Detect(ctx)`, then `Plan(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.
|
||||||
|
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 <ver>` or `[!] arduino-cli drift: got <X>, want <Y>`.
|
||||||
|
- `wokwi-cli --version` → parse first line's version-like token, compare to `cfg.Renode.WokwiVersion`. Same output format.
|
||||||
|
3. Summary line: `<converged>/<total> converged, <pending> need-install, <drift> drift, <errors> 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.<timestamp>` copy before the theme apply step. Document clearly in the install command's help text.
|
||||||
Reference in New Issue
Block a user