Adding a system action or package manager backend
This repo's TUI is entirely data-driven from src/personal_os_setup/tasks/factory.py::get_system_action_sections(). Never wire a button directly in frontend/app.py — add it to a section builder in factory.py instead. The frontend renders whatever sections/actions the factory returns generically (one TabPane per section, one Button per action), except for two special-cased sections it knows by name: "Sync dotfiles" (renders the chezmoi tree/toolbar) and "🚀 Start" (renders the onboarding markdown before the doc-link buttons).
For anything involving the chezmoi source tree itself (config/chezmoi/) — adding a run_* script, or explaining why a synced file's companion script didn't fire — see [[chezmoi-scripts]] first; the dotfiles tree's targeted-apply scoping is easy to get wrong.
⚠️ Every SystemAction.run you write or edit here is a real system-mutating command (installs a package, edits/overwrites a config file, changes the default shell, touches drivers, etc.) once a user clicks it in the running app. That's expected — it's the app's whole purpose — but when you (as the coding agent) are implementing/testing one of these, never invoke it against the real host yourself to "check it works." Read the code, run it through the unit-test mocks (see [[run-tests]]), and if you genuinely need to exercise the real command, confirm the exact command with the user first. See CLAUDE.md § "Safety: always confirm before system-mutating actions".
Adding a new action to an existing section
- Find the right per-domain builder function in
factory.py (e.g. _dotfiles_section, _system_section, _wsl_section) — sections are independently unit-tested in tests/unit/test_factory.py, one test class per section.
- Append a
SystemAction(...) to that builder's returned list. Fields available: label (button text), run: Callable[[], TaskResult], run_with_prompt/prompt_label/prompt_initial (for free-text input actions), confirm/confirm_message (for destructive actions), backup_target: Path | None (file gets copied with a timestamp suffix before run executes), group: str | None (adjacent actions sharing a group render on one row).
- The actual logic (
run callable) lives in tasks/system/<domain>.py, not in factory.py — factory.py only assembles SystemActions and returns TaskResults from imported functions.
- Add/extend a test in
tests/unit/test_factory.py using the existing _actions_in(system, distro, section_name) / _action_in(...) helpers — these call get_system_action_sections directly, no App instantiation needed.
Adding a brand-new section
- Write a
_xxx_section(...) -> Section function returning (section_name, [SystemAction, ...]) (Section = tuple[str, list[SystemAction]]).
- Wire it into
get_system_action_sections(), gated on system/distro as appropriate (see the if system in {...}: blocks there).
- Unless the section needs custom widgets (like dotfiles' tree or Start's markdown), it renders automatically via the generic
else branch in app.py::compose() — no frontend change needed.
- If it does need a custom widget, special-case it in
compose() by section-name constant (see how _DOTFILES_SECTION_NAME/_START_SECTION_NAME are matched) — keep the section-name string in app.py a plain literal matching what the factory returns, that's the existing convention (not a shared import).
Adding a new package-manager backend
- Implement the
PackageManager protocol (tasks/managers/base.py): is_installed, install, update, upgrade, cleanup.
- Each method does its own
shutil.which(...)/sudo_non_interactive_ok() check locally in the manager's own module (not through _shared.py) — this is deliberate, so unit tests can patch those checks at the manager's own module path.
- Once a check fails, build the
TaskResult/InstallResult via the shared helpers in tasks/managers/_shared.py: command_details()/format_failed_command(), sudo_required_task_result()/sudo_required_install_result(), missing_executable_task_result()/missing_executable_install_result(). Follow this "local check, shared result-builder" split rather than re-deriving the boilerplate.
- Register it in
tasks/factory.py::_PACKAGE_MANAGER_FACTORY_BY_DISTRO (maps (distro, manager_name) → manager class) and, if it should get its own tab/button, _PRIMARY_MANAGERS_BY_DISTRO.
- Add packages for it under the right distro/manager/category in
src/personal_os_setup/config/packages.yaml.
Before opening a PR
Run make test and make pre-commit — see [[ship-feature]] for the full git/PR workflow. Never click a button wired to a real package-manager/system command from a test; build a synthetic SystemAction with an in-memory run lambda instead (see tests/unit/test_app.py's confirm-flow test for the pattern).
1---2name: add-system-action3description: Use when adding a new TUI button/action (system action) or a new package-manager backend to personal-os-setup — e.g. "add a button to do X", "support a new package manager", "add a new tab/section". Covers the factory.py section-builder pattern, the managers/_shared.py boilerplate pattern, and the required test additions.4---56# Adding a system action or package manager backend78This repo's TUI is entirely data-driven from `src/personal_os_setup/tasks/factory.py::get_system_action_sections()`. Never wire a button directly in `frontend/app.py` — add it to a section builder in `factory.py` instead. The frontend renders whatever sections/actions the factory returns generically (one `TabPane` per section, one `Button` per action), except for two special-cased sections it knows by name: `"Sync dotfiles"` (renders the chezmoi tree/toolbar) and `"🚀 Start"` (renders the onboarding markdown before the doc-link buttons).910For anything involving the chezmoi source tree itself (`config/chezmoi/`) — adding a `run_*` script, or explaining why a synced file's companion script didn't fire — see [[chezmoi-scripts]] first; the dotfiles tree's targeted-apply scoping is easy to get wrong.1112⚠️ Every `SystemAction.run` you write or edit here is a real system-mutating command (installs a package, edits/overwrites a config file, changes the default shell, touches drivers, etc.) once a user clicks it in the running app. That's expected — it's the app's whole purpose — but when *you* (as the coding agent) are implementing/testing one of these, never invoke it against the real host yourself to "check it works." Read the code, run it through the unit-test mocks (see [[run-tests]]), and if you genuinely need to exercise the real command, confirm the exact command with the user first. See `CLAUDE.md` § "Safety: always confirm before system-mutating actions".1314## Adding a new action to an existing section15161. Find the right per-domain builder function in `factory.py` (e.g. `_dotfiles_section`, `_system_section`, `_wsl_section`) — sections are independently unit-tested in `tests/unit/test_factory.py`, one test class per section.172. Append a `SystemAction(...)` to that builder's returned list. Fields available: `label` (button text), `run: Callable[[], TaskResult]`, `run_with_prompt`/`prompt_label`/`prompt_initial` (for free-text input actions), `confirm`/`confirm_message` (for destructive actions), `backup_target: Path | None` (file gets copied with a timestamp suffix before `run` executes), `group: str | None` (adjacent actions sharing a `group` render on one row).183. The actual logic (`run` callable) lives in `tasks/system/<domain>.py`, not in `factory.py` — `factory.py` only assembles `SystemAction`s and returns `TaskResult`s from imported functions.194. Add/extend a test in `tests/unit/test_factory.py` using the existing `_actions_in(system, distro, section_name)` / `_action_in(...)` helpers — these call `get_system_action_sections` directly, no App instantiation needed.2021## Adding a brand-new section22231. Write a `_xxx_section(...) -> Section` function returning `(section_name, [SystemAction, ...])` (`Section = tuple[str, list[SystemAction]]`).242. Wire it into `get_system_action_sections()`, gated on `system`/`distro` as appropriate (see the `if system in {...}:` blocks there).253. Unless the section needs custom widgets (like dotfiles' tree or Start's markdown), it renders automatically via the generic `else` branch in `app.py::compose()` — no frontend change needed.264. If it *does* need a custom widget, special-case it in `compose()` by section-name constant (see how `_DOTFILES_SECTION_NAME`/`_START_SECTION_NAME` are matched) — keep the section-name string in `app.py` a plain literal matching what the factory returns, that's the existing convention (not a shared import).2728## Adding a new package-manager backend29301. Implement the `PackageManager` protocol (`tasks/managers/base.py`): `is_installed`, `install`, `update`, `upgrade`, `cleanup`.312. Each method does its own `shutil.which(...)`/`sudo_non_interactive_ok()` check *locally in the manager's own module* (not through `_shared.py`) — this is deliberate, so unit tests can patch those checks at the manager's own module path.323. Once a check fails, build the `TaskResult`/`InstallResult` via the shared helpers in `tasks/managers/_shared.py`: `command_details()`/`format_failed_command()`, `sudo_required_task_result()`/`sudo_required_install_result()`, `missing_executable_task_result()`/`missing_executable_install_result()`. Follow this "local check, shared result-builder" split rather than re-deriving the boilerplate.334. Register it in `tasks/factory.py::_PACKAGE_MANAGER_FACTORY_BY_DISTRO` (maps `(distro, manager_name)` → manager class) and, if it should get its own tab/button, `_PRIMARY_MANAGERS_BY_DISTRO`.345. Add packages for it under the right distro/manager/category in `src/personal_os_setup/config/packages.yaml`.3536## Before opening a PR3738Run `make test` and `make pre-commit` — see [[ship-feature]] for the full git/PR workflow. Never click a button wired to a real package-manager/system command from a test; build a synthetic `SystemAction` with an in-memory `run` lambda instead (see `tests/unit/test_app.py`'s confirm-flow test for the pattern).