devstuff 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. {devstuff-2.2.0 → devstuff-2.3.0}/.github/workflows/test-installs.yml +7 -2
  2. {devstuff-2.2.0 → devstuff-2.3.0}/CHANGELOG.md +12 -0
  3. {devstuff-2.2.0 → devstuff-2.3.0}/CLAUDE.md +45 -1
  4. {devstuff-2.2.0 → devstuff-2.3.0}/PKG-INFO +67 -6
  5. {devstuff-2.2.0 → devstuff-2.3.0}/README.md +65 -4
  6. devstuff-2.3.0/docs/specs/uv-backbone/development-plan.md +131 -0
  7. devstuff-2.3.0/docs/specs/uv-backbone/specifications.md +227 -0
  8. devstuff-2.3.0/docs/specs/uv-backbone/stack-decisions.md +119 -0
  9. {devstuff-2.2.0 → devstuff-2.3.0}/pyproject.toml +1 -1
  10. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/catalog.py +22 -0
  11. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/add_cmd.py +14 -0
  12. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/functions.yaml +98 -0
  13. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/generic.py +66 -5
  14. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/tools.yaml +71 -9
  15. {devstuff-2.2.0 → devstuff-2.3.0}/tests/integration/test_tools.py +19 -0
  16. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_catalog.py +108 -0
  17. devstuff-2.3.0/tests/test_generic.py +356 -0
  18. {devstuff-2.2.0 → devstuff-2.3.0}/uv.lock +1 -1
  19. devstuff-2.2.0/tests/test_generic.py +0 -161
  20. {devstuff-2.2.0 → devstuff-2.3.0}/.claude/skills/run-stuff/SKILL.md +0 -0
  21. {devstuff-2.2.0 → devstuff-2.3.0}/.claude/skills/run-stuff/smoke.sh +0 -0
  22. {devstuff-2.2.0 → devstuff-2.3.0}/.github/workflows/bump.yml +0 -0
  23. {devstuff-2.2.0 → devstuff-2.3.0}/.github/workflows/ci.yml +0 -0
  24. {devstuff-2.2.0 → devstuff-2.3.0}/.github/workflows/publish.yml +0 -0
  25. {devstuff-2.2.0 → devstuff-2.3.0}/.gitignore +0 -0
  26. {devstuff-2.2.0 → devstuff-2.3.0}/dev/Dockerfile +0 -0
  27. {devstuff-2.2.0 → devstuff-2.3.0}/dev/Dockerfile.ci +0 -0
  28. {devstuff-2.2.0 → devstuff-2.3.0}/dev/Makefile +0 -0
  29. {devstuff-2.2.0 → devstuff-2.3.0}/dev/docker-compose.yml +0 -0
  30. {devstuff-2.2.0 → devstuff-2.3.0}/devstuff +0 -0
  31. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/README.md +0 -0
  32. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/agent/development-plan.md +0 -0
  33. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/agent/specifications.md +0 -0
  34. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/agent/stack-decisions.md +0 -0
  35. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/ansible-config/development-plan.md +0 -0
  36. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/ansible-config/specifications.md +0 -0
  37. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/ansible-config/stack-decisions.md +0 -0
  38. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/bat-config/development-plan.md +0 -0
  39. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/bat-config/specifications.md +0 -0
  40. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/bat-config/stack-decisions.md +0 -0
  41. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/commitizen-config/development-plan.md +0 -0
  42. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/commitizen-config/specifications.md +0 -0
  43. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/commitizen-config/stack-decisions.md +0 -0
  44. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/docker-config/development-plan.md +0 -0
  45. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/docker-config/specifications.md +0 -0
  46. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/docker-config/stack-decisions.md +0 -0
  47. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/lazygit-config/development-plan.md +0 -0
  48. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/lazygit-config/specifications.md +0 -0
  49. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/lazygit-config/stack-decisions.md +0 -0
  50. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/precommit-config/development-plan.md +0 -0
  51. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/precommit-config/specifications.md +0 -0
  52. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/precommit-config/stack-decisions.md +0 -0
  53. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/starship-config/development-plan.md +0 -0
  54. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/starship-config/specifications.md +0 -0
  55. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/starship-config/stack-decisions.md +0 -0
  56. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/verbose-mode/development-plan.md +0 -0
  57. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/verbose-mode/specifications.md +0 -0
  58. {devstuff-2.2.0 → devstuff-2.3.0}/docs/specs/verbose-mode/stack-decisions.md +0 -0
  59. {devstuff-2.2.0 → devstuff-2.3.0}/install.sh +0 -0
  60. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/__init__.py +0 -0
  61. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/__main__.py +0 -0
  62. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/__init__.py +0 -0
  63. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/approval.py +0 -0
  64. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/bridges.py +0 -0
  65. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/catalog.py +0 -0
  66. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/completion.py +0 -0
  67. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/config.py +0 -0
  68. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/loop.py +0 -0
  69. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/ollama.py +0 -0
  70. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/preflight.py +0 -0
  71. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/primitives.py +0 -0
  72. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/registry.py +0 -0
  73. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/sandbox.py +0 -0
  74. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/session.py +0 -0
  75. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/transcript.py +0 -0
  76. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent/wizard.py +0 -0
  77. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent_tools.schema.json +0 -0
  78. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/agent_tools.yaml +0 -0
  79. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/base.py +0 -0
  80. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/cli.py +0 -0
  81. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/__init__.py +0 -0
  82. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/agent_cmd.py +0 -0
  83. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/catalog_cmd.py +0 -0
  84. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/configure_cmd.py +0 -0
  85. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/delete_cmd.py +0 -0
  86. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/docs_cmd.py +0 -0
  87. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/doctor_cmd.py +0 -0
  88. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/functions_cmd.py +0 -0
  89. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/help_cmd.py +0 -0
  90. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/install_cmd.py +0 -0
  91. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/list_cmd.py +0 -0
  92. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/remove_cmd.py +0 -0
  93. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/run_cmd.py +0 -0
  94. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/skills_cmd.py +0 -0
  95. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/commands/update_cmd.py +0 -0
  96. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/__init__.py +0 -0
  97. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/ansible/__init__.py +0 -0
  98. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/ansible/detect.py +0 -0
  99. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/ansible/model.py +0 -0
  100. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/ansible/render.py +0 -0
  101. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/ansible/validate.py +0 -0
  102. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/ansible/wizard.py +0 -0
  103. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/bat/__init__.py +0 -0
  104. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/bat/detect.py +0 -0
  105. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/bat/model.py +0 -0
  106. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/bat/preview.py +0 -0
  107. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/bat/render.py +0 -0
  108. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/bat/wizard.py +0 -0
  109. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/commitizen/__init__.py +0 -0
  110. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/commitizen/detect.py +0 -0
  111. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/commitizen/model.py +0 -0
  112. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/commitizen/render.py +0 -0
  113. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/commitizen/validate.py +0 -0
  114. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/commitizen/wizard.py +0 -0
  115. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/docker/__init__.py +0 -0
  116. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/docker/detect.py +0 -0
  117. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/docker/model.py +0 -0
  118. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/docker/render.py +0 -0
  119. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/docker/validate.py +0 -0
  120. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/docker/wizard.py +0 -0
  121. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/lazygit/__init__.py +0 -0
  122. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/lazygit/detect.py +0 -0
  123. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/lazygit/model.py +0 -0
  124. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/lazygit/render.py +0 -0
  125. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/lazygit/validate.py +0 -0
  126. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/lazygit/wizard.py +0 -0
  127. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/precommit/__init__.py +0 -0
  128. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/precommit/detect.py +0 -0
  129. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/precommit/model.py +0 -0
  130. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/precommit/render.py +0 -0
  131. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/precommit/validate.py +0 -0
  132. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/precommit/wizard.py +0 -0
  133. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/starship/__init__.py +0 -0
  134. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/starship/fonts.py +0 -0
  135. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/starship/model.py +0 -0
  136. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/starship/preview.py +0 -0
  137. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/starship/render.py +0 -0
  138. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/configure/starship/wizard.py +0 -0
  139. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/doctor.py +0 -0
  140. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/function_runner.py +0 -0
  141. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/functions.schema.json +0 -0
  142. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/functions_catalog.py +0 -0
  143. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/functions_registry.py +0 -0
  144. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/registry.py +0 -0
  145. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/ui.py +0 -0
  146. {devstuff-2.2.0 → devstuff-2.3.0}/src/dev_setup/verbose.py +0 -0
  147. {devstuff-2.2.0 → devstuff-2.3.0}/tests/__init__.py +0 -0
  148. {devstuff-2.2.0 → devstuff-2.3.0}/tests/integration/__init__.py +0 -0
  149. {devstuff-2.2.0 → devstuff-2.3.0}/tests/integration/conftest.py +0 -0
  150. {devstuff-2.2.0 → devstuff-2.3.0}/tests/integration/test_agent_smoke.py +0 -0
  151. {devstuff-2.2.0 → devstuff-2.3.0}/tests/integration/test_agent_tui.py +0 -0
  152. {devstuff-2.2.0 → devstuff-2.3.0}/tests/integration/test_commitizen_config.py +0 -0
  153. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_agent.py +0 -0
  154. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_agent_completion.py +0 -0
  155. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_agent_loop.py +0 -0
  156. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_agent_sandbox.py +0 -0
  157. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_agent_tools.py +0 -0
  158. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_agent_wizard.py +0 -0
  159. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_configure_ansible.py +0 -0
  160. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_configure_bat.py +0 -0
  161. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_configure_commitizen.py +0 -0
  162. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_configure_docker.py +0 -0
  163. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_configure_lazygit.py +0 -0
  164. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_configure_precommit.py +0 -0
  165. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_configure_starship.py +0 -0
  166. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_doctor.py +0 -0
  167. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_functions.py +0 -0
  168. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_registry.py +0 -0
  169. {devstuff-2.2.0 → devstuff-2.3.0}/tests/test_verbose.py +0 -0
@@ -51,27 +51,32 @@ jobs:
51
51
  fail-fast: false # let all tools run even when some fail
52
52
  matrix:
53
53
  tool:
54
+ - ansible # uvx tool, requires uv
55
+ - ansible-vault # shares ansible's tool environment
54
56
  - aws
57
+ - bat
55
58
  - commitizen # uvx tool, requires uv
56
- - eza
57
59
  - gh
58
60
  - git-lfs
59
61
  - go
62
+ - homebrew # slow: builds from source
60
63
  - htop
61
64
  - ipython # uvx tool, requires uv
62
65
  - java
63
66
  - lazygit
67
+ - llm-checker # npm tool, installs nvm first via `requires`
68
+ - lmstudio
64
69
  - mkcert
65
70
  - nerd-font
66
71
  - nvm
67
72
  - php
68
73
  - pi # installs nvm first via its declared `requires`
69
74
  - pre-commit # uvx tool, requires uv
75
+ - python # uv-managed CPython, requires uv
70
76
  - ruby # compiles from source — this job takes ~15 minutes
71
77
  - saml2aws
72
78
  - starship
73
79
  - uv
74
- - whichllm # uvx tool, requires uv
75
80
  - yq
76
81
 
77
82
  steps:
@@ -1,3 +1,15 @@
1
+ ## v2.3.0 (2026-08-13)
2
+
3
+ ### Features
4
+
5
+ - make uv the install backbone, and fix ansible (#42)
6
+
7
+ ## v2.2.1 (2026-08-05)
8
+
9
+ ### Bug Fixes
10
+
11
+ - repair the two failing builtin install integration tests (#40)
12
+
1
13
  ## v2.2.0 (2026-08-05)
2
14
 
3
15
  ### Features
@@ -83,6 +83,36 @@ pattern for upgrading an already-installed tool (latest or a pinned version); fo
83
83
  `bash` types "update" is a full reinstall, since there's no narrower mechanism, so the command
84
84
  layer confirms before re-running it.
85
85
 
86
+ **uv is the backbone for Python-packaged tools** (spec in `docs/specs/uv-backbone/`). The
87
+ `uvx`/`pip` types accept three optional fields beyond `pip_name` — `uv_with`,
88
+ `uv_executables_from`, `uv_python` — assembled into argv by `_uv_install_flags` in `generic.py`
89
+ and rejected by `validate_catalog()` on every other type. Three things about them are
90
+ load-bearing:
91
+ - **`uv tool install` only exposes console scripts of the *requested* package.** The `ansible`
92
+ distribution is a collections bundle declaring none of its own, so `pip_name: ansible` alone
93
+ installs one unusable `ansible-community` binary and reports success. `uv_executables_from:
94
+ [ansible-core]` is what produces the eleven real ones. Don't "simplify" it to
95
+ `pip_name: ansible-core` — that silently drops the bundled collections.
96
+ - **`_update_uvx` never uses `uv tool upgrade`** — both paths are `uv tool install --force`
97
+ (`<pkg>==<ver>` pinned, `<pkg>@latest` otherwise). `uv tool upgrade` can't take a requirement
98
+ *and* is a no-op on an already-pinned tool, so the obvious pairing makes a pin a one-way door.
99
+ `--force` writes a fresh receipt, so the `uv_*` flags are re-passed on every update; that is
100
+ deliberate, since it also means a newly added `uv_with` applies on update, not just reinstall.
101
+ Anything printed telling a user how to escape a pin must be measured — two plausible remedies
102
+ ("re-run without `--version`", "run `devstuff install`") are both false, the second because
103
+ `install_cmd` returns early on `is_installed()`.
104
+ - **The `python` entry provisions CPython through uv** (`uv python install --default`). Its
105
+ `check_cmd` is the delicate part: every host has a `python3`, so the check requires the default
106
+ shim to *resolve inside* `uv python dir`, uses `test -x` so a dangling shim from a removed
107
+ interpreter reads as absent, and guards `test -n "$d"` because an empty `uv python dir` would
108
+ collapse the `case` pattern to `/*` and match everything. Removal uninstalls only the version
109
+ the default shim points at — `uv python uninstall --all` would also destroy managed interpreters
110
+ that existing `uv tool` environments were built against.
111
+ - **`_remove_uvx` honours an explicit `remove_script`**, mirroring `_remove_apt`. Two entries can
112
+ share one `pip_name` (`ansible-vault` shares `ansible`'s), and there `uv tool uninstall` would
113
+ tear out the shared environment. Extras need no field: `pip_name` reaches `subprocess` as one
114
+ argv element, never a shell, so `pip_name: "ansible-lint[lock]"` already works.
115
+
86
116
  **Verbosity** (`verbose.py`, spec in `docs/specs/verbose-mode/`): one process-wide level —
87
117
  `0` / `-v` / `-vv` — set by a Click callback and read by the subprocess helpers, never threaded
88
118
  through call signatures. Three things about it are load-bearing:
@@ -114,7 +144,15 @@ Add an entry to `src/dev_setup/tools.yaml` using an existing `type` (`npm`, `pip
114
144
  and per-type examples. Then:
115
145
  - Add the key to `.github/workflows/test-installs.yml`'s matrix (or to `_SKIP` in
116
146
  `tests/integration/test_tools.py` with a reason, if it can't run in CI).
117
- - Add it to the relevant table in README.md ("Built-in packages").
147
+ `test_ci_matrix_covers_every_builtin_tool` enforces this, in both directions a stale matrix
148
+ entry names a pytest node id that matches nothing, so the job exits 4 and the workflow files a
149
+ GitHub issue every week. That went unnoticed for `eza`/`whichllm` from July until the test
150
+ existed, which is why it is a test and not a line in this file.
151
+ - Add it to the relevant table in README.md ("Built-in packages"). Not enforced by a test —
152
+ the tables are prose-formatted and grouped loosely, so check by hand.
153
+ - If it needs an executable that belongs to a *dependency* rather than the package itself, add
154
+ it to `_EXTRA_EXECUTABLES` in `tests/integration/test_tools.py`; `is_installed()` alone would
155
+ pass on a half-installed tool (see the `uvx` notes above).
118
156
  - No Python code changes needed — `GenericTool` already knows how to run every existing type.
119
157
 
120
158
  ## Adding a new tool *type* (e.g. a `composer`/PHP-package type)
@@ -194,6 +232,12 @@ through its exit code — only whether it ran. A "found nothing" answer should t
194
232
  say so, or the user gets a red "command failed" banner under a correct result; keep non-zero for
195
233
  "could not perform the lookup" (see `whats-on-port`'s comment on this).
196
234
 
235
+ The way this rule actually gets broken is not a stray `exit 1` — it is **a bare `[ -n "$x" ] &&
236
+ echo …` as the last statement of a branch**, which silently becomes the script's exit status when
237
+ the test is false. `which-ansible` shipped that bug in its first draft: "no global ansible found",
238
+ a correct answer, exited 1. Use a full `if`, and end such scripts with an explicit `exit 0`. It is
239
+ only catchable by *running* every branch, not by reading them.
240
+
197
241
  Not yet built: an `add` wizard and `catalog import`/`export` for functions, analogous to the
198
242
  ones tools already have.
199
243
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: devstuff
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Development environment setup CLI for Linux
5
5
  Project-URL: Repository, https://github.com/thesawdawg/dev-setup-py
6
6
  Author-email: Sawyer <sawyerksu@gmail.com>
@@ -287,7 +287,7 @@ How "update" is performed depends on the package's install `type`:
287
287
  | Type | Latest | Specific version |
288
288
  |------|--------|-------------------|
289
289
  | `npm` | `npm install -g <pkg>@latest` | `npm install -g <pkg>@<version>` |
290
- | `pip` / `uvx` | `uv tool upgrade <pkg>` | `uv tool upgrade <pkg>==<version>` |
290
+ | `pip` / `uvx` | `uv tool install --force <pkg>@latest` | `uv tool install --force <pkg>==<version>` |
291
291
  | `apt` | `apt-get install --only-upgrade` | `apt-get install <pkg>=<version>` (single package only) |
292
292
  | `git` | `git pull` (+ re-run `git_install_cmd`) | not supported — repos are cloned shallow (`--depth=1`) |
293
293
  | `script` / `bash` | Re-runs the install script | not supported — no version parameter to inject |
@@ -296,6 +296,17 @@ For `script`/`bash` packages, "update" is a full reinstall (the same script that
296
296
  `sudo` runs again), since there's no narrower update mechanism available. `devstuff update` asks
297
297
  for confirmation before doing this.
298
298
 
299
+ `pip`/`uvx` packages go through `uv tool install` rather than `uv tool upgrade` in **both**
300
+ columns. `uv tool upgrade` takes a tool *name*, so it cannot accept `<pkg>==<version>` at all, and
301
+ it is a no-op on a tool that was previously pinned — using it for the "latest" column would mean
302
+ pinning a package once left no way to move it again. `<pkg>@latest` re-resolves and clears any
303
+ existing pin in one step. Pinning warns that the pin holds until the next `devstuff update`
304
+ without `--version`.
305
+
306
+ Any `uv_with` / `uv_executables_from` / `uv_python` fields on the package are re-applied on every
307
+ update, so adding one to your catalog takes effect on the next update rather than needing a
308
+ reinstall.
309
+
299
310
  ---
300
311
 
301
312
  ### `configure`
@@ -1132,6 +1143,7 @@ ones tools already have — for now, custom functions are hand-edited YAML at
1132
1143
  | `whats-on-port` | network | script | Find which process is listening on a port | `port`, `protocol` (optional: `tcp`/`udp`/`all`) |
1133
1144
  | `acc-check` | web-dev | script | Run the pi coding agent's `/dogfood` skill against a web URL | `url`, `instruction` (optional) |
1134
1145
  | `aws-saml-reauth` | web-dev | script | Reauthorize the AWS CLI via `saml2aws login --force` | `profile` (optional) |
1146
+ | `which-ansible` | validation | script | Show which ansible a directory will actually use — the project's uv venv or the global one | `path` (optional) |
1135
1147
 
1136
1148
  #### `whats-on-port`
1137
1149
 
@@ -1163,6 +1175,32 @@ so a missing `ss` names the apt package instead of surfacing "command not found"
1163
1175
 
1164
1176
  ---
1165
1177
 
1178
+ #### `which-ansible`
1179
+
1180
+ ```bash
1181
+ devstuff run which-ansible # the current directory
1182
+ devstuff run which-ansible ~/infra # somewhere else
1183
+ ```
1184
+
1185
+ Answers "if I type `ansible-playbook` here, which one runs?" — the project's own uv venv, or the
1186
+ global tool install. It walks up for a `pyproject.toml`/`uv.lock` the way uv does, then reports
1187
+ whichever applies:
1188
+
1189
+ - a synced project venv, plus the `uv run ansible-playbook` invocation that uses it (and what a
1190
+ bare `ansible-playbook` would pick instead);
1191
+ - ansible declared but not synced yet, pointing at `uv sync`;
1192
+ - no ansible in the project, or no project at all, naming the global one.
1193
+
1194
+ Every one of those is a success and exits 0 — including "this project has no ansible", which is
1195
+ an answer, not a failure. Non-zero is reserved for not being able to look at all (no uv, or a
1196
+ path that isn't a directory).
1197
+
1198
+ It exists because devstuff's ansible and a uv-managed ansible repo are now the *same shape* — both
1199
+ a uv-created venv — so the question stopped being "which tool is broken" and became "which
1200
+ environment am I in". See `docs/specs/uv-backbone/` for why the global one moved off apt.
1201
+
1202
+ ---
1203
+
1166
1204
  ## Built-in packages
1167
1205
 
1168
1206
  ### Core
@@ -1182,13 +1220,18 @@ Optional utilities you may want on some machines.
1182
1220
  | Key | Name | Description | Help |
1183
1221
  |-----|------|-------------|------|
1184
1222
  | `ansible` | Ansible | Automation engine for configuration management and app deployment (`devstuff configure ansible`) | `ansible --help` |
1223
+ | `ansible-vault` | Ansible Vault | Encrypt and manage secrets within Ansible projects (bundled with Ansible) | `ansible-vault --help` |
1185
1224
  | `aws` | AWS CLI | Amazon Web Services CLI v2 | `aws help` |
1186
1225
  | `bat` | bat | cat replacement with syntax highlighting and git integration (`devstuff configure bat`) | `bat --help` |
1187
1226
  | `commitizen` | Commitizen | Conventional-commit prompt, semantic version bumping, and changelog generation (`devstuff configure commitizen`) | `cz --help` |
1188
- | `eza` | eza | Modern ls replacement with git status, icons, and tree view | `eza --help` |
1189
1227
  | `gh` | GitHub CLI | GitHub's official CLI | `gh --help` |
1228
+ | `git-lfs` | Git LFS | Git extension for versioning large files | `git lfs --help` |
1190
1229
  | `htop` | htop | Interactive process and resource monitor | `man htop` |
1230
+ | `homebrew` | Homebrew | Package manager for Linux (and macOS) | `brew --help` |
1231
+ | `ipython` | IPython | Enhanced interactive Python shell with rich tab completion and magic commands | `ipython --help` |
1191
1232
  | `lazygit` | lazygit | TUI git client for fast, keyboard-driven git workflows (`devstuff configure lazygit`) | `lazygit --help` |
1233
+ | `llm-checker` | llm-checker | Check LLM compatibility with your hardware | `llm-checker --help` |
1234
+ | `lmstudio` | LM Studio | Run large language models locally with a GUI | `lms --help` |
1192
1235
  | `mkcert` | mkcert | Zero-config local HTTPS certificates | `mkcert --help` |
1193
1236
  | `nerd-font` | JetBrainsMono Nerd Font | Patched font supplying the icons Starship and other CLI tools draw | `fc-list \| grep -i "nerd font"` |
1194
1237
  | `ollama` | Ollama | Run large language models locally | `ollama --help` |
@@ -1205,6 +1248,7 @@ Optional utilities you may want on some machines.
1205
1248
  |-----|------|-------------|------|
1206
1249
  | `go` | Go | Go programming language toolchain | `go help` |
1207
1250
  | `java` | Java 21 (OpenJDK) | OpenJDK 21 LTS - JDK and JRE | `java --help` |
1251
+ | `python` | Python (uv-managed) | CPython provisioned by uv, installed as the default `python`/`python3` | `uv python list` |
1208
1252
  | `ruby` | Ruby (rbenv) | Ruby via rbenv version manager + ruby-build | `ruby --version` |
1209
1253
 
1210
1254
  ---
@@ -1247,7 +1291,10 @@ tools:
1247
1291
  | `docs_url` | no | URL opened by `devstuff docs <key>` |
1248
1292
  | `requires` | no | List of package keys that must already be installed |
1249
1293
  | `npm_name` | npm | npm package name |
1250
- | `pip_name` | pip | PyPI package name |
1294
+ | `pip_name` | pip | PyPI package name. Extras work as-is: `pip_name: "ansible-lint[lock]"` |
1295
+ | `uv_with` | no (pip/uvx) | Extra packages installed into the tool's environment (`uv tool install --with`) |
1296
+ | `uv_executables_from` | no (pip/uvx) | Also expose console scripts from these packages (`--with-executables-from`). Needed when a package's entry points live in a dependency |
1297
+ | `uv_python` | no (pip/uvx) | Pin the tool environment's Python (`--python`), e.g. `"3.12"` |
1251
1298
  | `apt_packages` | apt | Space-separated list of apt packages |
1252
1299
  | `git_url` | git | Repository URL to clone |
1253
1300
  | `git_install_cmd` | git | Bash command run inside the cloned repo after clone |
@@ -1256,7 +1303,21 @@ tools:
1256
1303
  | `install_script` | bash | Full bash script to run on install |
1257
1304
  | `remove_script` | bash | Full bash script to run on remove |
1258
1305
 
1259
- Unknown fields fail validation. `requires` defaults to `["nvm"]` for `npm` tools and `["uv"]` for `pip`/`uvx` tools unless explicitly set.
1306
+ Unknown fields fail validation — including the three `uv_*` fields on any type other than `pip`/`uvx`. `requires` defaults to `["nvm"]` for `npm` tools and `["uv"]` for `pip`/`uvx` tools unless explicitly set.
1307
+
1308
+ `uv_executables_from` is worth understanding before you skip it: `uv tool install` exposes the console scripts of the *requested* package only. The `ansible` distribution is a collections bundle that declares none of its own — `ansible`, `ansible-playbook` and `ansible-vault` are all entry points of its `ansible-core` dependency — so without this field the install reports success and puts a single unusable `ansible-community` on `PATH`:
1309
+
1310
+ ```yaml
1311
+ version: 1
1312
+ tools:
1313
+ ansible:
1314
+ name: Ansible
1315
+ type: uvx
1316
+ check_cmd: ansible
1317
+ pip_name: ansible
1318
+ uv_executables_from:
1319
+ - ansible-core
1320
+ ```
1260
1321
 
1261
1322
  ### Examples
1262
1323
 
@@ -260,7 +260,7 @@ How "update" is performed depends on the package's install `type`:
260
260
  | Type | Latest | Specific version |
261
261
  |------|--------|-------------------|
262
262
  | `npm` | `npm install -g <pkg>@latest` | `npm install -g <pkg>@<version>` |
263
- | `pip` / `uvx` | `uv tool upgrade <pkg>` | `uv tool upgrade <pkg>==<version>` |
263
+ | `pip` / `uvx` | `uv tool install --force <pkg>@latest` | `uv tool install --force <pkg>==<version>` |
264
264
  | `apt` | `apt-get install --only-upgrade` | `apt-get install <pkg>=<version>` (single package only) |
265
265
  | `git` | `git pull` (+ re-run `git_install_cmd`) | not supported — repos are cloned shallow (`--depth=1`) |
266
266
  | `script` / `bash` | Re-runs the install script | not supported — no version parameter to inject |
@@ -269,6 +269,17 @@ For `script`/`bash` packages, "update" is a full reinstall (the same script that
269
269
  `sudo` runs again), since there's no narrower update mechanism available. `devstuff update` asks
270
270
  for confirmation before doing this.
271
271
 
272
+ `pip`/`uvx` packages go through `uv tool install` rather than `uv tool upgrade` in **both**
273
+ columns. `uv tool upgrade` takes a tool *name*, so it cannot accept `<pkg>==<version>` at all, and
274
+ it is a no-op on a tool that was previously pinned — using it for the "latest" column would mean
275
+ pinning a package once left no way to move it again. `<pkg>@latest` re-resolves and clears any
276
+ existing pin in one step. Pinning warns that the pin holds until the next `devstuff update`
277
+ without `--version`.
278
+
279
+ Any `uv_with` / `uv_executables_from` / `uv_python` fields on the package are re-applied on every
280
+ update, so adding one to your catalog takes effect on the next update rather than needing a
281
+ reinstall.
282
+
272
283
  ---
273
284
 
274
285
  ### `configure`
@@ -1105,6 +1116,7 @@ ones tools already have — for now, custom functions are hand-edited YAML at
1105
1116
  | `whats-on-port` | network | script | Find which process is listening on a port | `port`, `protocol` (optional: `tcp`/`udp`/`all`) |
1106
1117
  | `acc-check` | web-dev | script | Run the pi coding agent's `/dogfood` skill against a web URL | `url`, `instruction` (optional) |
1107
1118
  | `aws-saml-reauth` | web-dev | script | Reauthorize the AWS CLI via `saml2aws login --force` | `profile` (optional) |
1119
+ | `which-ansible` | validation | script | Show which ansible a directory will actually use — the project's uv venv or the global one | `path` (optional) |
1108
1120
 
1109
1121
  #### `whats-on-port`
1110
1122
 
@@ -1136,6 +1148,32 @@ so a missing `ss` names the apt package instead of surfacing "command not found"
1136
1148
 
1137
1149
  ---
1138
1150
 
1151
+ #### `which-ansible`
1152
+
1153
+ ```bash
1154
+ devstuff run which-ansible # the current directory
1155
+ devstuff run which-ansible ~/infra # somewhere else
1156
+ ```
1157
+
1158
+ Answers "if I type `ansible-playbook` here, which one runs?" — the project's own uv venv, or the
1159
+ global tool install. It walks up for a `pyproject.toml`/`uv.lock` the way uv does, then reports
1160
+ whichever applies:
1161
+
1162
+ - a synced project venv, plus the `uv run ansible-playbook` invocation that uses it (and what a
1163
+ bare `ansible-playbook` would pick instead);
1164
+ - ansible declared but not synced yet, pointing at `uv sync`;
1165
+ - no ansible in the project, or no project at all, naming the global one.
1166
+
1167
+ Every one of those is a success and exits 0 — including "this project has no ansible", which is
1168
+ an answer, not a failure. Non-zero is reserved for not being able to look at all (no uv, or a
1169
+ path that isn't a directory).
1170
+
1171
+ It exists because devstuff's ansible and a uv-managed ansible repo are now the *same shape* — both
1172
+ a uv-created venv — so the question stopped being "which tool is broken" and became "which
1173
+ environment am I in". See `docs/specs/uv-backbone/` for why the global one moved off apt.
1174
+
1175
+ ---
1176
+
1139
1177
  ## Built-in packages
1140
1178
 
1141
1179
  ### Core
@@ -1155,13 +1193,18 @@ Optional utilities you may want on some machines.
1155
1193
  | Key | Name | Description | Help |
1156
1194
  |-----|------|-------------|------|
1157
1195
  | `ansible` | Ansible | Automation engine for configuration management and app deployment (`devstuff configure ansible`) | `ansible --help` |
1196
+ | `ansible-vault` | Ansible Vault | Encrypt and manage secrets within Ansible projects (bundled with Ansible) | `ansible-vault --help` |
1158
1197
  | `aws` | AWS CLI | Amazon Web Services CLI v2 | `aws help` |
1159
1198
  | `bat` | bat | cat replacement with syntax highlighting and git integration (`devstuff configure bat`) | `bat --help` |
1160
1199
  | `commitizen` | Commitizen | Conventional-commit prompt, semantic version bumping, and changelog generation (`devstuff configure commitizen`) | `cz --help` |
1161
- | `eza` | eza | Modern ls replacement with git status, icons, and tree view | `eza --help` |
1162
1200
  | `gh` | GitHub CLI | GitHub's official CLI | `gh --help` |
1201
+ | `git-lfs` | Git LFS | Git extension for versioning large files | `git lfs --help` |
1163
1202
  | `htop` | htop | Interactive process and resource monitor | `man htop` |
1203
+ | `homebrew` | Homebrew | Package manager for Linux (and macOS) | `brew --help` |
1204
+ | `ipython` | IPython | Enhanced interactive Python shell with rich tab completion and magic commands | `ipython --help` |
1164
1205
  | `lazygit` | lazygit | TUI git client for fast, keyboard-driven git workflows (`devstuff configure lazygit`) | `lazygit --help` |
1206
+ | `llm-checker` | llm-checker | Check LLM compatibility with your hardware | `llm-checker --help` |
1207
+ | `lmstudio` | LM Studio | Run large language models locally with a GUI | `lms --help` |
1165
1208
  | `mkcert` | mkcert | Zero-config local HTTPS certificates | `mkcert --help` |
1166
1209
  | `nerd-font` | JetBrainsMono Nerd Font | Patched font supplying the icons Starship and other CLI tools draw | `fc-list \| grep -i "nerd font"` |
1167
1210
  | `ollama` | Ollama | Run large language models locally | `ollama --help` |
@@ -1178,6 +1221,7 @@ Optional utilities you may want on some machines.
1178
1221
  |-----|------|-------------|------|
1179
1222
  | `go` | Go | Go programming language toolchain | `go help` |
1180
1223
  | `java` | Java 21 (OpenJDK) | OpenJDK 21 LTS - JDK and JRE | `java --help` |
1224
+ | `python` | Python (uv-managed) | CPython provisioned by uv, installed as the default `python`/`python3` | `uv python list` |
1181
1225
  | `ruby` | Ruby (rbenv) | Ruby via rbenv version manager + ruby-build | `ruby --version` |
1182
1226
 
1183
1227
  ---
@@ -1220,7 +1264,10 @@ tools:
1220
1264
  | `docs_url` | no | URL opened by `devstuff docs <key>` |
1221
1265
  | `requires` | no | List of package keys that must already be installed |
1222
1266
  | `npm_name` | npm | npm package name |
1223
- | `pip_name` | pip | PyPI package name |
1267
+ | `pip_name` | pip | PyPI package name. Extras work as-is: `pip_name: "ansible-lint[lock]"` |
1268
+ | `uv_with` | no (pip/uvx) | Extra packages installed into the tool's environment (`uv tool install --with`) |
1269
+ | `uv_executables_from` | no (pip/uvx) | Also expose console scripts from these packages (`--with-executables-from`). Needed when a package's entry points live in a dependency |
1270
+ | `uv_python` | no (pip/uvx) | Pin the tool environment's Python (`--python`), e.g. `"3.12"` |
1224
1271
  | `apt_packages` | apt | Space-separated list of apt packages |
1225
1272
  | `git_url` | git | Repository URL to clone |
1226
1273
  | `git_install_cmd` | git | Bash command run inside the cloned repo after clone |
@@ -1229,7 +1276,21 @@ tools:
1229
1276
  | `install_script` | bash | Full bash script to run on install |
1230
1277
  | `remove_script` | bash | Full bash script to run on remove |
1231
1278
 
1232
- Unknown fields fail validation. `requires` defaults to `["nvm"]` for `npm` tools and `["uv"]` for `pip`/`uvx` tools unless explicitly set.
1279
+ Unknown fields fail validation — including the three `uv_*` fields on any type other than `pip`/`uvx`. `requires` defaults to `["nvm"]` for `npm` tools and `["uv"]` for `pip`/`uvx` tools unless explicitly set.
1280
+
1281
+ `uv_executables_from` is worth understanding before you skip it: `uv tool install` exposes the console scripts of the *requested* package only. The `ansible` distribution is a collections bundle that declares none of its own — `ansible`, `ansible-playbook` and `ansible-vault` are all entry points of its `ansible-core` dependency — so without this field the install reports success and puts a single unusable `ansible-community` on `PATH`:
1282
+
1283
+ ```yaml
1284
+ version: 1
1285
+ tools:
1286
+ ansible:
1287
+ name: Ansible
1288
+ type: uvx
1289
+ check_cmd: ansible
1290
+ pip_name: ansible
1291
+ uv_executables_from:
1292
+ - ansible-core
1293
+ ```
1233
1294
 
1234
1295
  ### Examples
1235
1296
 
@@ -0,0 +1,131 @@
1
+ # uv as the install backbone — development plan
2
+
3
+ Companion to `specifications.md` and `stack-decisions.md`.
4
+
5
+ ## Suggested first vertical slice
6
+
7
+ **FR-2 + FR-7 + the `ansible` catalog entry.** Adding just `uv_executables_from` and rewriting the
8
+ `ansible` entry is enough to prove the whole design end to end — it is the field with the most
9
+ mechanism behind it, and `ansible --version` reporting eleven binaries off a uv venv is an
10
+ unambiguous pass/fail. `uv_with` and `uv_python` are trivial once the argv-building shape exists.
11
+
12
+ ## Milestones
13
+
14
+ ### M1 — engine fields (FR-1..FR-8) — **done 2026-08-12**
15
+
16
+ Touching, per CLAUDE.md's five-place rule for schema changes:
17
+
18
+ 1. `src/dev_setup/catalog.py` — add `uv_with`, `uv_executables_from`, `uv_python` to
19
+ `SUPPORTED_FIELDS`; add the type-gate in `validate_catalog()` (FR-4). No `requires` inference
20
+ changes — `uvx` already infers `["uv"]`, and that stays correct.
21
+ 2. `src/dev_setup/generic.py` — the three fields in `GenericTool.__init__`, `from_dict`, `to_dict`
22
+ (FR-6); argv building in `_install_uvx` (FR-7).
23
+ 3. `src/dev_setup/commands/add_cmd.py` — three optional prompts in the `uvx`/`pip` branch (FR-8).
24
+ 4. `README.md` — type table, YAML schema field table, and a worked ansible example.
25
+ 5. `tests/test_catalog.py` — accepted on `uvx`, rejected on `apt`/`bash`, `to_dict` round-trip,
26
+ and `to_dict` omitting them when empty.
27
+
28
+ **Exit:** `uv run pytest` green; a hand-written user catalog entry using all three fields loads.
29
+
30
+ ### M2 — the update-path bug (FR-9, FR-10, FR-10a) — **done 2026-08-12**
31
+
32
+ `_update_uvx` routes **both** paths through `uv tool install --force` plus the FR-1..3 flags:
33
+ `<pkg>==<ver>` when pinned, `<pkg>@latest` otherwise.
34
+
35
+ The plan originally kept `uv tool upgrade` for the unpinned path; measuring it during
36
+ implementation showed that would have left a pinned tool permanently stuck (F-10), so the
37
+ requirement changed. Two draft warning texts named remedies that did not work (F-11) before the
38
+ current one was verified end to end.
39
+
40
+ This is a **pre-existing bug**, not a regression from M1 — `devstuff update <uvx-tool> --version X`
41
+ has never worked (F-5).
42
+
43
+ **Exit (met):** unit tests assert the argv shape for the pinned branch, the unpinned branch, flag
44
+ re-application, and the warning text. Verified against real uv: pin → 4.16.0 with a specifier in
45
+ the receipt; plain update → 4.17.0 with the specifier gone. Ansible keeps all eleven executables
46
+ across both a pinned and an unpinned update.
47
+
48
+ ### M3 — ansible conversion (FR-11..FR-14) — **done 2026-08-12**
49
+
50
+ Rewrite `ansible` and `ansible-vault` in `tools.yaml` per the conversion table. Drop `ansible`'s
51
+ apt `remove_script` (FR-13). Keep `ansible-vault`'s.
52
+
53
+ `.github/workflows/test-installs.yml` — **this plan was wrong**: neither `ansible` nor
54
+ `ansible-vault` was in the matrix. Both added. Neither is in `_SKIP`, so the integration test
55
+ auto-parametrisation already covered them locally; only CI was missing them.
56
+
57
+ The integration assertion was strengthened as the risk table demanded: `_EXTRA_EXECUTABLES` in
58
+ `tests/integration/test_tools.py` now asserts `ansible-playbook`/`ansible-galaxy`/`ansible-vault`/
59
+ `ansible-doc` resolve on `PATH`, not just that `is_installed()` is true.
60
+
61
+ **Exit:** `cd dev && make run-tests TOOL=ansible` passes, and a manual check that all eleven
62
+ executables resolve and `ansible --version` reports the uv venv interpreter (FR-12).
63
+
64
+ ### M4 — uv-managed Python (FR-15..FR-17) — **done 2026-08-12**
65
+
66
+ New `python` entry in `tools.yaml`. The risk here is entirely in `check_cmd` (FR-16, SD-8) — write
67
+ it against `uv python list --only-installed` / `uv python dir` rather than `command -v python3`, and
68
+ verify it answers *false* on a machine that has system Python but no uv-managed one.
69
+
70
+ **Exit (met):** added to the CI matrix. The check was verified false-then-true across an install,
71
+ and separately against the two ways it could lie: a dangling shim from a removed interpreter
72
+ (F-14, which this machine already had) and a missing uv collapsing the match pattern to `/*`
73
+ (F-15). Removal is targeted rather than `--all` (F-13).
74
+
75
+ ### M5 — per-repo ansible helper (FR-18..FR-20) — **done 2026-08-12**
76
+
77
+ New `functions.yaml` entry. Also update `src/dev_setup/functions.schema.json` if any field shape
78
+ changes — CLAUDE.md notes it is hand-maintained and drifts silently.
79
+
80
+ **Exit (met):** run in five states — no uv (exit 1), bad path (exit 1), `/tmp` with no project
81
+ (0), this repo which is a uv project without ansible (0), and a real uv ansible repo before and
82
+ after `uv sync` (0). The first draft failed FR-19 exactly as predicted (F-16).
83
+
84
+ ### M6 — docs — **done 2026-08-12**
85
+
86
+ Done as described, and the sweep turned up drift that predates this work:
87
+
88
+ - The `update` mechanism table still documented `uv tool upgrade` for `pip`/`uvx` (both columns).
89
+ - `eza` was deleted from the catalog in a1a5126 (2026-07-01) but never from the README table **or
90
+ the CI matrix**, and `whichllm` was renamed to `llm-checker` in the same commit with the matrix
91
+ never updated. A matrix entry naming a tool that no longer exists makes pytest exit 4 — so those
92
+ two jobs have failed every weekly run since July, and the workflow's report step files a GitHub
93
+ issue about them each time.
94
+ - Five real tools (`git-lfs`, `homebrew`, `ipython`, `llm-checker`, `lmstudio`) were missing from
95
+ the README tables, and four (`bat`, `homebrew`, `llm-checker`, `lmstudio`) from the CI matrix.
96
+
97
+ All fixed, and `test_ci_matrix_covers_every_builtin_tool` now enforces the matrix half in both
98
+ directions — verified to fail on a stale entry *and* on a missing one. The README half is left to
99
+ manual review; its tables are prose-grouped and a parser would be more brittle than the drift it
100
+ catches.
101
+
102
+ ## Testing strategy
103
+
104
+ - **Unit** (`uv run pytest`, ~0.3s) — schema acceptance/rejection, `to_dict` round-trip, and the
105
+ argv built by `_install_uvx`/`_update_uvx`. The argv assertions are the ones that matter; they are
106
+ what would catch a regression back to a bare `uv tool install`.
107
+ - **Integration** (`cd dev && make run-tests TOOL=ansible`) — real install in a throwaway container.
108
+ The assertion should be strengthened beyond `is_installed()`: **assert `ansible-playbook` resolves**,
109
+ not just `ansible`. F-1 is precisely a case where a weaker check passes on a broken install.
110
+ - **Manual, once** — FR-14: run `uv run ansible-playbook --version` inside a uv-managed ansible repo
111
+ with the global tool installed, and confirm it still picks the project venv.
112
+
113
+ ## Risks
114
+
115
+ | Risk | Mitigation |
116
+ |---|---|
117
+ | The integration test passes on a broken install because `is_installed()` only checks `ansible`. | Assert on `ansible-playbook` too. This is the exact shape of F-1 and the single most likely way this change ships broken. |
118
+ | uv changes `--with-executables-from` semantics or spelling. | Pinned only by uv's CLI stability. The failure is loud (unknown flag), not silent. Acceptable. |
119
+ | A user's existing catalog override for `ansible` keeps `type: apt`. | Catalog precedence means their override wins and they keep the old behaviour — correct, but worth a line in the README/changelog so it is not a mystery. |
120
+ | ansible-core lands on an interpreter it does not support (OQ-2). | `uv_python` exists specifically to pin it; leave unset until observed. |
121
+ | Removing the apt `remove_script` strands a previously apt-installed ansible. | Anyone who installed the old way has an apt ansible that `uv tool uninstall` will not remove. Mention in the changelog and in `devstuff docs ansible`; do not attempt an automatic migration — guessing at removing a system package is worse than saying so. |
122
+
123
+ ## Definition of done
124
+
125
+ - `devstuff install ansible` puts all eleven executables on `PATH` from a uv-managed venv.
126
+ - `uv run ansible-playbook` inside a uv ansible repo is unaffected.
127
+ - `devstuff update commitizen --version 4.16.0` works (it currently cannot).
128
+ - All three new fields are documented in the README schema table and validated on the wrong types.
129
+ - Unit suite green. `make run-tests TOOL=ansible` **not yet run** — the containerised
130
+ clean-install path remains unexercised for `ansible`, `ansible-vault` and `python`.
131
+ - This spec directory matches what was built.