devstuff 1.15.1__tar.gz → 1.17.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 (98) hide show
  1. {devstuff-1.15.1 → devstuff-1.17.0}/.github/workflows/test-installs.yml +1 -0
  2. {devstuff-1.15.1 → devstuff-1.17.0}/CHANGELOG.md +22 -0
  3. devstuff-1.17.0/CLAUDE.md +350 -0
  4. {devstuff-1.15.1 → devstuff-1.17.0}/PKG-INFO +284 -3
  5. {devstuff-1.15.1 → devstuff-1.17.0}/README.md +283 -2
  6. devstuff-1.17.0/dev/Makefile +60 -0
  7. devstuff-1.17.0/docs/specs/README.md +42 -0
  8. devstuff-1.17.0/docs/specs/agent/development-plan.md +194 -0
  9. devstuff-1.17.0/docs/specs/agent/specifications.md +204 -0
  10. devstuff-1.17.0/docs/specs/agent/stack-decisions.md +177 -0
  11. devstuff-1.17.0/docs/specs/starship-config/development-plan.md +74 -0
  12. devstuff-1.17.0/docs/specs/starship-config/specifications.md +164 -0
  13. devstuff-1.17.0/docs/specs/starship-config/stack-decisions.md +187 -0
  14. {devstuff-1.15.1 → devstuff-1.17.0}/pyproject.toml +1 -1
  15. devstuff-1.17.0/src/dev_setup/agent/__init__.py +6 -0
  16. devstuff-1.17.0/src/dev_setup/agent/approval.py +102 -0
  17. devstuff-1.17.0/src/dev_setup/agent/bridges.py +125 -0
  18. devstuff-1.17.0/src/dev_setup/agent/catalog.py +158 -0
  19. devstuff-1.17.0/src/dev_setup/agent/completion.py +50 -0
  20. devstuff-1.17.0/src/dev_setup/agent/config.py +170 -0
  21. devstuff-1.17.0/src/dev_setup/agent/loop.py +129 -0
  22. devstuff-1.17.0/src/dev_setup/agent/ollama.py +243 -0
  23. devstuff-1.17.0/src/dev_setup/agent/preflight.py +98 -0
  24. devstuff-1.17.0/src/dev_setup/agent/primitives.py +101 -0
  25. devstuff-1.17.0/src/dev_setup/agent/registry.py +156 -0
  26. devstuff-1.17.0/src/dev_setup/agent/sandbox.py +286 -0
  27. devstuff-1.17.0/src/dev_setup/agent/session.py +334 -0
  28. devstuff-1.17.0/src/dev_setup/agent/transcript.py +52 -0
  29. devstuff-1.17.0/src/dev_setup/agent/wizard.py +104 -0
  30. devstuff-1.17.0/src/dev_setup/agent_tools.schema.json +112 -0
  31. devstuff-1.17.0/src/dev_setup/agent_tools.yaml +145 -0
  32. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/cli.py +5 -0
  33. devstuff-1.17.0/src/dev_setup/commands/agent_cmd.py +143 -0
  34. devstuff-1.17.0/src/dev_setup/commands/configure_cmd.py +123 -0
  35. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/help_cmd.py +6 -0
  36. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/install_cmd.py +19 -1
  37. devstuff-1.17.0/src/dev_setup/configure/__init__.py +56 -0
  38. devstuff-1.17.0/src/dev_setup/configure/starship/__init__.py +1 -0
  39. devstuff-1.17.0/src/dev_setup/configure/starship/fonts.py +47 -0
  40. devstuff-1.17.0/src/dev_setup/configure/starship/model.py +850 -0
  41. devstuff-1.17.0/src/dev_setup/configure/starship/preview.py +157 -0
  42. devstuff-1.17.0/src/dev_setup/configure/starship/render.py +268 -0
  43. devstuff-1.17.0/src/dev_setup/configure/starship/wizard.py +396 -0
  44. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/function_runner.py +18 -2
  45. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/tools.yaml +48 -0
  46. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/ui.py +4 -2
  47. devstuff-1.17.0/tests/integration/test_agent_smoke.py +107 -0
  48. devstuff-1.17.0/tests/integration/test_agent_tui.py +92 -0
  49. devstuff-1.17.0/tests/test_agent.py +379 -0
  50. devstuff-1.17.0/tests/test_agent_completion.py +197 -0
  51. devstuff-1.17.0/tests/test_agent_loop.py +368 -0
  52. devstuff-1.17.0/tests/test_agent_sandbox.py +348 -0
  53. devstuff-1.17.0/tests/test_agent_tools.py +417 -0
  54. devstuff-1.17.0/tests/test_agent_wizard.py +205 -0
  55. devstuff-1.17.0/tests/test_configure_starship.py +682 -0
  56. {devstuff-1.15.1 → devstuff-1.17.0}/tests/test_functions.py +29 -0
  57. {devstuff-1.15.1 → devstuff-1.17.0}/uv.lock +1 -1
  58. devstuff-1.15.1/CLAUDE.md +0 -185
  59. devstuff-1.15.1/dev/Makefile +0 -39
  60. {devstuff-1.15.1 → devstuff-1.17.0}/.claude/skills/run-stuff/SKILL.md +0 -0
  61. {devstuff-1.15.1 → devstuff-1.17.0}/.claude/skills/run-stuff/smoke.sh +0 -0
  62. {devstuff-1.15.1 → devstuff-1.17.0}/.github/workflows/bump.yml +0 -0
  63. {devstuff-1.15.1 → devstuff-1.17.0}/.github/workflows/ci.yml +0 -0
  64. {devstuff-1.15.1 → devstuff-1.17.0}/.github/workflows/publish.yml +0 -0
  65. {devstuff-1.15.1 → devstuff-1.17.0}/.gitignore +0 -0
  66. {devstuff-1.15.1 → devstuff-1.17.0}/dev/Dockerfile +0 -0
  67. {devstuff-1.15.1 → devstuff-1.17.0}/dev/Dockerfile.ci +0 -0
  68. {devstuff-1.15.1 → devstuff-1.17.0}/dev/docker-compose.yml +0 -0
  69. {devstuff-1.15.1 → devstuff-1.17.0}/dev-setup +0 -0
  70. {devstuff-1.15.1 → devstuff-1.17.0}/install.sh +0 -0
  71. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/__init__.py +0 -0
  72. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/__main__.py +0 -0
  73. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/base.py +0 -0
  74. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/catalog.py +0 -0
  75. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/__init__.py +0 -0
  76. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/add_cmd.py +0 -0
  77. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/catalog_cmd.py +0 -0
  78. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/delete_cmd.py +0 -0
  79. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/docs_cmd.py +0 -0
  80. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/functions_cmd.py +0 -0
  81. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/list_cmd.py +0 -0
  82. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/remove_cmd.py +0 -0
  83. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/run_cmd.py +0 -0
  84. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/skills_cmd.py +0 -0
  85. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/commands/update_cmd.py +0 -0
  86. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/functions.schema.json +0 -0
  87. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/functions.yaml +0 -0
  88. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/functions_catalog.py +0 -0
  89. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/functions_registry.py +0 -0
  90. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/generic.py +0 -0
  91. {devstuff-1.15.1 → devstuff-1.17.0}/src/dev_setup/registry.py +0 -0
  92. {devstuff-1.15.1 → devstuff-1.17.0}/tests/__init__.py +0 -0
  93. {devstuff-1.15.1 → devstuff-1.17.0}/tests/integration/__init__.py +0 -0
  94. {devstuff-1.15.1 → devstuff-1.17.0}/tests/integration/conftest.py +0 -0
  95. {devstuff-1.15.1 → devstuff-1.17.0}/tests/integration/test_tools.py +0 -0
  96. {devstuff-1.15.1 → devstuff-1.17.0}/tests/test_catalog.py +0 -0
  97. {devstuff-1.15.1 → devstuff-1.17.0}/tests/test_generic.py +0 -0
  98. {devstuff-1.15.1 → devstuff-1.17.0}/tests/test_registry.py +0 -0
@@ -61,6 +61,7 @@ jobs:
61
61
  - java
62
62
  - lazygit
63
63
  - mkcert
64
+ - nerd-font
64
65
  - nvm
65
66
  - php
66
67
  - pi # installs nvm first via its declared `requires`
@@ -1,3 +1,25 @@
1
+ ## v1.17.0 (2026-07-31)
2
+
3
+ ### Feat
4
+
5
+ - starship config font validation and install
6
+ - **configure**: add a starship prompt setup wizard with live preview
7
+
8
+ ## v1.16.0 (2026-07-24)
9
+
10
+ ### Feat
11
+
12
+ - **agent**: first-run setup wizard for agent configuration
13
+ - **agent**: multi-line input and slash completion in the REPL
14
+ - **agent**: milestone 4 — transcripts, /history, docs, schema
15
+ - **agent**: complete milestone 2 — tools, sandbox denylist, approval loop
16
+ - **agent**: add workspace sandbox, launch guard, and smoke suite
17
+ - **agent**: add ollama-backed agent scaffold (milestone 1)
18
+
19
+ ### Fix
20
+
21
+ - **agent**: correct argument mapping and output capture for function tools
22
+
1
23
  ## v1.15.1 (2026-07-20)
2
24
 
3
25
  ### Fix
@@ -0,0 +1,350 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ `dev-setup` is a Python CLI (Click + Rich + questionary) that installs, removes, and tracks
8
+ developer tools on Linux. There is no per-tool Python code — every tool (built-in or
9
+ user-added) is a data record in a YAML catalog, executed by one generic engine
10
+ (`GenericTool` in `src/dev_setup/generic.py`). Adding a tool is a YAML edit, not a code change;
11
+ adding a new *install mechanism* (a "type") is a code change touched in ~5 places (see below).
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ uv run dev-setup <cmd> # run from source (repo root)
17
+ ./dev-setup <cmd> # bash wrapper — bootstraps .venv on first run, then execs Python
18
+ uv run pytest # unit tests only (integration tests skipped by default, ~0.3s)
19
+ uv run pytest -m integration # real installs — requires sudo + network, run inside Docker (see below)
20
+ uv run pytest tests/test_catalog.py::test_user_catalog_overrides_bundled_tool_in_place # single test
21
+ ```
22
+
23
+ `pip` is not available in this dev environment — `uv pip install -e .` / `uv run` are the way in.
24
+ Interactive commands (`install`, `add`, `remove`, `delete`, `configure`) open questionary/click
25
+ prompts and hang in non-TTY shells; don't try to drive them from a piped/non-interactive shell.
26
+ To exercise one end-to-end anyway, fork a pty (`pty.fork()`) and write keystrokes to the fd —
27
+ that is how the starship wizard's full flow was verified.
28
+
29
+ ### Integration tests (real installs, Docker-isolated)
30
+
31
+ Integration tests actually install each builtin tool and assert `is_installed()` afterward. Run
32
+ via the Makefile in `dev/`, which builds the wheel and a throwaway CI image so nothing touches
33
+ the host:
34
+
35
+ ```bash
36
+ cd dev
37
+ make run-tests # all builtin tools
38
+ make run-tests TOOL=uv # just one tool's install test
39
+ make run-tests PYTEST_ARGS="-x --tb=long"
40
+ ```
41
+
42
+ `tests/integration/test_tools.py` auto-parametrizes over every `builtin` registry entry except
43
+ those listed in its `_SKIP` dict (currently `docker`, `ollama` — can't run inside a container).
44
+ The `.github/workflows/test-installs.yml` weekly canary runs the same suite per-tool as a matrix
45
+ in fresh containers and files/updates a GitHub issue on failure.
46
+
47
+ ### Releases
48
+
49
+ Versioning is Commitizen-driven (`[tool.commitizen]` in `pyproject.toml`, conventional commits,
50
+ `version_provider = "pep621"`). `.github/workflows/bump.yml` bumps the version and changelog on
51
+ merge to master; `publish.yml` ships to PyPI. Don't hand-edit the version in `pyproject.toml`.
52
+
53
+ ## Architecture
54
+
55
+ ```
56
+ src/dev_setup/
57
+ ├── __main__.py # python -m dev_setup entry point
58
+ ├── cli.py # Click group, command registration (see _register_commands)
59
+ ├── base.py # Tool ABC (is_installed/install/remove), WhichTool, bashrc patch helpers
60
+ ├── catalog.py # YAML load/validate/merge/import/export — the schema is enforced here
61
+ ├── registry.py # Loads the effective catalog into a live in-memory Tool registry
62
+ ├── generic.py # GenericTool — the ONE engine that implements every install type
63
+ ├── tools.yaml # Bundled built-in catalog (core/tools/languages categories)
64
+ ├── ui.py # Rich console + questionary wrappers (spinners, prompts, styled output)
65
+ ├── configure/ # Per-tool setup wizards (see "Configurators" below)
66
+ └── commands/ # One Click command per file: list, install, remove, add, delete, docs, catalog
67
+ ```
68
+
69
+ **Catalog precedence** (`catalog.load_effective_catalog`): bundled `tools.yaml` loads first →
70
+ user YAML at `~/.config/dev-setup/tools.yaml` overrides matching keys in place and appends
71
+ new ones. `registry.py` turns that merged dict into `GenericTool` instances; a tool is
72
+ `builtin` only if it came from bundled and has no user override.
73
+
74
+ **Execution model**: `GenericTool` is a dataclass; `install()`/`remove()`/`is_installed()`/
75
+ `update()` each look up `self.install_type` (`npm`, `pip`, `uvx`, `apt`, `git`, `script`,
76
+ `bash`) in a strategy-dispatch dict (`_INSTALLERS`/`_REMOVERS`/`_CHECKERS`/`_UPDATERS` in
77
+ `generic.py`) rather than an if/elif chain, and shell out via `subprocess`. `install()` raises
78
+ `RuntimeError`/`CalledProcessError` on failure — there's no result enum, command handlers just
79
+ catch and report. `bash`-type scripts are written to a temp file and run with `bash <file>` for
80
+ full parsing fidelity (not `bash -c "<string>"`). `dev-setup update` reuses the same dispatch
81
+ pattern for upgrading an already-installed tool (latest or a pinned version); for `script`/
82
+ `bash` types "update" is a full reinstall, since there's no narrower mechanism, so the command
83
+ layer confirms before re-running it.
84
+
85
+ **Two ways a tool gets defined**: built-in (an entry added directly to `src/dev_setup/tools.yaml`,
86
+ `builtin=True`) or custom (created via the `dev-setup add` wizard, `dev-setup catalog import`,
87
+ or hand-edited YAML, landing in the user catalog). Both use the identical schema — the only
88
+ difference is which file the key lives in and `category`.
89
+
90
+ ## Adding a new built-in tool
91
+
92
+ Add an entry to `src/dev_setup/tools.yaml` using an existing `type` (`npm`, `pip`, `uvx`, `apt`,
93
+ `git`, `script`, `bash`) — see README.md "Custom packages → YAML schema" for the full field list
94
+ and per-type examples. Then:
95
+ - Add the key to `.github/workflows/test-installs.yml`'s matrix (or to `_SKIP` in
96
+ `tests/integration/test_tools.py` with a reason, if it can't run in CI).
97
+ - Add it to the relevant table in README.md ("Built-in packages").
98
+ - No Python code changes needed — `GenericTool` already knows how to run every existing type.
99
+
100
+ ## Adding a new tool *type* (e.g. a `composer`/PHP-package type)
101
+
102
+ `php` itself (the PHP runtime) is already a built-in `bash`-type tool — what's *not* supported is
103
+ installing PHP packages via Composer as their own first-class type (analogous to how `npm` and
104
+ `uvx`/`pip` are first-class today). Adding a type like `composer` touches every layer:
105
+
106
+ 1. **`catalog.py`** — add any new field names (e.g. `composer_name`) to `SUPPORTED_FIELDS`, and
107
+ if the type implies an auto-`requires` (like `npm` → `["nvm"]`, `pip`/`uvx` → `["uv"]`), add
108
+ that inference in both `validate_catalog()` and `GenericTool.__init__`/`to_dict()` (`generic.py`)
109
+ — these two must stay in sync or `to_dict()`'s "don't persist auto-inferred requires" logic
110
+ will drift from validation.
111
+ 2. **`generic.py` `GenericTool`** — add the new field(s) to `__init__`, `from_dict`, `to_dict`;
112
+ add a branch in `is_installed()`, `install()`, `remove()`, and (if version detection needs
113
+ type-specific logic) `_type_cmd()`.
114
+ 3. **`commands/add_cmd.py`** — add the type to the wizard's type list (`install_type = ui.select(...)`)
115
+ and add the type-specific prompt branch (mirrors the existing `npm`/`apt`/`git` branches).
116
+ 4. **README.md** — add the type to the type table and the YAML schema field table, plus a
117
+ worked example under "Custom packages → Examples".
118
+ 5. **Tests** — extend `tests/test_catalog.py` for schema validation of the new fields; if you add
119
+ a built-in tool using the new type, it's auto-picked-up by
120
+ `tests/integration/test_tools.py`'s parametrization (add to CI matrix / `_SKIP` as above).
121
+
122
+ ## Functions/scripts (a separate subsystem from tools)
123
+
124
+ `src/dev_setup/functions.yaml` + `functions_catalog.py` + `functions_registry.py` +
125
+ `function_runner.py` are a parallel, independent catalog/registry from tools — functions
126
+ aren't installed/removed, they're invoked (`dev-setup run <key>`), so they get their own
127
+ schema instead of overloading `GenericTool`. Some duplication with `catalog.py`/`registry.py`
128
+ is deliberate (see "Key design decisions" below).
129
+
130
+ `src/dev_setup/functions.schema.json` is a hand-maintained JSON Schema documenting every
131
+ field for editor tooling (YAML language server autocomplete/validation) — it is **not**
132
+ loaded or enforced at runtime (no `jsonschema` dependency), so if you add/change a field or
133
+ a constraint in `functions_catalog.py`'s `validate_catalog()`, update the schema file too or
134
+ they'll silently drift apart.
135
+
136
+ **Why two function `type`s exist**: a `dev-setup` command is its own child process, so
137
+ anything it does with `subprocess` (env vars, `cd`, aliases) is invisible to the shell that
138
+ invoked it the moment the process exits. `type: script` is for functions that don't need to
139
+ mutate the calling shell (runs as a subprocess, like a tool's `install_script`). `type:
140
+ shell-eval` is for functions that must (`ssh-agent`, `nvm use`-style tools) — it has two
141
+ `register` modes:
142
+ - `register: bashrc` (default) — `dev-setup functions enable <key>` patches a real shell
143
+ function into `~/.bashrc` via `base.patch_bashrc`; the user calls it directly by name in a
144
+ new shell afterward. `dev-setup run` refuses to run these directly (there's nothing it
145
+ *can* do) and points at `functions enable` instead.
146
+ - `register: eval` — `dev-setup run <key>` prints resolved shell code to stdout for
147
+ `eval "$(dev-setup run key args)"`. This path must never print anything else to stdout
148
+ (no `ui.*` calls, no prompts) since it would corrupt what gets `eval`'d — missing required
149
+ params are reported on stderr and exit non-zero instead of being prompted for.
150
+
151
+ **Named params, not positional**: catalog `params` entries become named shell vars in the
152
+ script body (`"$key_path"`, not `$1`). `function_runner.py` injects a prelude mapping real
153
+ argv positions to those names for `script`/bashrc-registered functions (`key_path="$1"`); for
154
+ `register: eval`, which has no argv channel of its own once `eval`'d, it instead bakes the
155
+ already-resolved values in as shell-quoted literals (`key_path='/path/with spaces'`).
156
+
157
+ **Gotcha if you touch `render_bashrc_function`**: it must strip blank lines from the function
158
+ body. `remove_bashrc_block` (shared with tool bashrc patches) treats the first blank line
159
+ after its marker as the end of the block, so a blank line inside the rendered function would
160
+ make `functions disable` orphan everything after it — closing brace included.
161
+
162
+ Functions have a `category` field (defaults to `custom`, freeform — not an enum) that
163
+ `functions list` groups/sorts by, mirroring tools. A `script`-type function that shells out to
164
+ another CLI should guard on `command -v <tool>` and point at `dev-setup install <tool>` in the
165
+ error rather than let a raw "command not found" surface — see `validate-yaml`/`aws-saml-reauth`
166
+ in `functions.yaml`. If that CLI is only reachable via nvm (like `pi`), source
167
+ `"$HOME/.nvm/nvm.sh"` first (see `acc-check`) — `script`-type functions run via a non-login,
168
+ non-interactive `bash <tmpfile>`, so `~/.bashrc`/nvm's shell init never runs on their own.
169
+
170
+ Not yet built: an `add` wizard and `catalog import`/`export` for functions, analogous to the
171
+ ones tools already have.
172
+
173
+ ## Configurators (`configure/`) — tool-specific wizards, deliberately *not* catalog-driven
174
+
175
+ `src/dev_setup/configure/` holds per-tool setup wizards (`devstuff configure <tool>`), registered
176
+ in a `CONFIGURATORS` dict keyed by catalog tool key. `starship` is the first one:
177
+ `configure/starship/{model,render,preview,wizard}.py`.
178
+
179
+ **Why this one breaks the YAML-catalog rule.** Installation generalises into ~7 mechanisms, which
180
+ is what makes `GenericTool` possible. Configuration does not: starship's config is a TOML file of
181
+ format strings, palette tables and powerline transition glyphs; another tool's would be shell
182
+ exports or `git config` calls. Expressing this wizard in YAML would need conditional questions, an
183
+ ordered section model with colour roles, and a template language — a programming language spelled
184
+ in YAML. So configurators are Python, dispatched from a dict, the same shape as `_INSTALLERS` in
185
+ `generic.py`. Full reasoning in `docs/specs/starship-config/stack-decisions.md` (SD-1).
186
+
187
+ A configurator that finds a missing *prerequisite* should offer to install it through
188
+ `install_cmd.install_by_key(key)` rather than telling the user to run a second command — that is
189
+ what the starship wizard does for `nerd-font`.
190
+
191
+ **Adding a configurator** — a new module plus one dict entry. The module must expose
192
+ `run(*, target: Path | None = None)` (returns `None` if the user cancelled; writes nothing until
193
+ they confirm) and `config_path() -> Path`. Everything generic — the picker, install-state check,
194
+ `--list`/`--path`/`--output`, and the post-install offer in `install_cmd.py` — reads the registry,
195
+ so none of it needs touching. There is deliberately **no `configure:` field in `tools.yaml`**: user
196
+ catalogs are strictly validated, so a catalog naming a nonexistent configurator would add a
197
+ load-time failure mode for nothing.
198
+
199
+ **Within the starship configurator, `model.py` is the data and everything else reads it.**
200
+ `SECTIONS` (ordered — declaration order *is* prompt order), `PALETTES` and `PRESETS` drive both the
201
+ TOML emitter and the offline preview, which is what stops those two from drifting. Adding a section
202
+ or palette is one entry; no other file changes.
203
+
204
+ **Things learned from the real binary — don't "simplify" these away:**
205
+ - `symbol` is emitted for every section whose body contains `$symbol`, *including as an empty
206
+ string*. Omit it and starship's built-in Nerd Font glyph leaks into the `plain` preset. The
207
+ converse is unit-tested: a section carrying an icon its body can't render is a bug (that's why
208
+ `directory`, which has no `symbol` key at all, has no icon).
209
+ - Not every module spells the colour key `style` — hence `Section.style_key` (`username` wants
210
+ `style_user`). `battery` is deliberately absent: it accepts neither `style` nor `symbol` (both
211
+ live in its `[[battery.display]]` array), so it would cost three quirk fields for one section.
212
+ - `kubernetes`, `time`, `azure`, `status` and `shlvl` ship *disabled*; listing them in `format`
213
+ isn't enough, they need `disabled = false`. `os` and `git_metrics` are absent for the same
214
+ reason `battery` is: `os` takes its symbol from an `[os.symbols]` distro table, `git_metrics`
215
+ has `added_style`/`deleted_style` instead of `style`.
216
+ - Format strings are emitted as TOML **literal** strings (`'…'`) so starship's `$`, `[`, and `\[`
217
+ grammar needs no escaping. The multi-line top-level `format` is the one basic string, and it
218
+ contains no backslashes of its own beyond the line continuations.
219
+ - Powerline transitions are emitted per **run of consecutive sections sharing a palette role**, not
220
+ per section — otherwise two adjacent language segments draw an arrow between two identical
221
+ backgrounds. The three powerline presets differ *only* in the four glyphs in their `Powerline`
222
+ record (`POWERLINES` in `model.py`); nothing in the emitter knows which variant it is drawing.
223
+ - Any literal text the emitter writes into a format string is escaped with `_escape_format()`.
224
+ This is not theoretical: `success_symbol = '[$](bold fg:ok)'` made starship read `$` as the start
225
+ of a variable name, so the `plain` preset shipped with *no prompt symbol at all* until it was
226
+ caught by sweeping every combination and asserting empty stderr.
227
+ - A custom module (`custom.compose`) is a normal `Section`, but its dotted key has to be written
228
+ `${custom.compose}` in `format` — hence `Section.ref`. It also runs a shell command on **every**
229
+ prompt, so it carries a `when` guard, and anything needing the network is disqualified.
230
+ - Symbols for new sections come from `starship print-config --default` — the module's own shipped
231
+ glyph — rather than being picked from a Nerd Font chart that can't be verified without the font.
232
+ - The sample project must never gain a `bun.lock`: starship's `nodejs` module lists it as a
233
+ *negative* detector, so it silently switches the Node section off in previews.
234
+ - The live preview runs `starship prompt` with `STARSHIP_SHELL=nu`, not `bash`: for bash, starship
235
+ wraps escapes in readline's `\[`/`\]` markers, which are invisible inside a `PS1` and print
236
+ literally anywhere else. It also overrides `PWD` (starship prefers it over the real cwd) and
237
+ makes a second `--right` call, since `right_format` is not part of the left prompt.
238
+ - Every preview failure path returns `None` and degrades to the offline renderer. A preview must
239
+ never be able to end the wizard.
240
+ - The Nerd Font gate (`configure/starship/fonts.py`) is allowed to answer "don't know": without
241
+ fontconfig there is nothing to enumerate, and `None` means *stay silent* rather than warn. It
242
+ also refuses to install over SSH — the glyphs are drawn by the client's terminal — and always
243
+ says that installing a font does not repoint the terminal at it. The install itself goes through
244
+ the ordinary catalog path (`nerd-font` in `tools.yaml` → `install_cmd.install_by_key`), never a
245
+ private download (SD-10).
246
+
247
+ Not yet built: configurators for anything other than starship, and round-tripping an existing
248
+ hand-edited config back into wizard state (the timestamped backup is the safety net instead).
249
+
250
+ ## Specs (`docs/specs/`)
251
+
252
+ Design documents live in `docs/specs/<feature>/` — `specifications.md` (numbered, testable
253
+ requirements), `stack-decisions.md` (choices *and* rejected alternatives with reasons), and
254
+ `development-plan.md` (milestones, testing strategy, risks). See `docs/specs/README.md` for the
255
+ conventions.
256
+
257
+ When working on a feature that has a spec, read it first and **keep it current in the same PR** —
258
+ a spec that no longer matches the code is worse than no spec. Record resolved open questions with
259
+ a date and the answer rather than deleting them, and when a live finding contradicts a
260
+ requirement, update the requirement and note what was learned. New features of any size should
261
+ get a spec directory before implementation starts.
262
+
263
+ ## The agent (`devstuff agent`) — a third catalog subsystem
264
+
265
+ `src/dev_setup/agent/` + `agent_tools.yaml` is an interactive session where a local Ollama model
266
+ calls devstuff's tools plus a workspace-scoped filesystem/shell kit. It follows the same
267
+ catalog-driven shape as tools and functions: `agent/catalog.py` validates `agent_tools.yaml`
268
+ (bundled → user override), `agent/registry.py` turns it into `AgentTool` objects, and
269
+ `agent/primitives.py` dispatches by `impl` through a `_PRIMITIVES` dict — the same
270
+ strategy-dispatch pattern as `_INSTALLERS` in `generic.py`.
271
+
272
+ **Adding an agent tool**: if it bridges to something that already exists (a catalog tool, a
273
+ `functions.yaml` entry), it is a pure YAML edit — `impl: catalog` or `impl: function` plus a
274
+ `target`. Only a genuinely new mechanism needs a new `impl: primitive` callable registered in
275
+ `_PRIMITIVES`. `type: script` functions are auto-exposed as `fn_<key>` tools with no edit at all.
276
+
277
+ **Security invariants — do not weaken these without deliberate thought:**
278
+ - `Workspace.resolve()` in `agent/sandbox.py` is the *only* thing standing between the model and
279
+ the filesystem. It resolves symlinks and `..` **before** the containment check; reordering that
280
+ reintroduces a symlink escape. Prompt instructions are not a control and never will be.
281
+ - The command denylist (`check_command`) runs **before** any confirmation prompt and is
282
+ deliberately not disabled by `--yolo`. The prompt is a human attention filter; attention
283
+ degrades over a session, the denylist does not.
284
+ - Credential dirs are blocked for **read** as well as write — exfiltrating an SSH key into a model
285
+ context is as bad as overwriting one. `~/.config/dev-setup` is readable but not writable, so
286
+ the agent cannot author catalogs (FR-14a).
287
+ - `assess()` (the launch guard) is advisory UX, not a control. Keep that distinction in comments;
288
+ the risk is a future reader mistaking a warning for enforcement.
289
+
290
+ **Everything in the loop returns errors to the model rather than raising.** Unknown tool, bad
291
+ arguments, sandbox refusal, a crashing tool — all become `role: tool` messages so the agent can
292
+ re-plan. A malformed tool call must never end a session. `max_iterations` is what stops a runaway.
293
+
294
+ **`cd` is a tool, not a shell command**, for the same reason `shell-eval` functions exist: each
295
+ `run_command` is its own subprocess, so a shell `cd` evaporates on exit. And `shell-eval`
296
+ functions are excluded from the toolbox entirely — they exist to mutate the calling shell, which
297
+ an agent subprocess has no way to do (mirroring the guard in `run_cmd.py`).
298
+
299
+ **Ollama response-shape handling all lives in `ollama.parse_message()`.** Builds differ on
300
+ whether reasoning arrives in `message.thinking` or as inline `<think>` tags in `content`, and
301
+ whether tool calls arrive in `tool_calls` or as JSON inside `content`. Think-stripping must stay
302
+ *ahead* of the content-JSON fallback, or a reasoning preamble hides the tool call. Keep new
303
+ quirks in that one function.
304
+
305
+ **Model choice is measured, not assumed.** `ollama show` reports a `capabilities` array;
306
+ preflight requires `tools` in it. The default (`gemma4:latest`) was picked by running the same
307
+ scaffolding prompt across local models — lfm2.5 had `write_file` available and still shelled out
308
+ to `echo >`, corrupting the content through shell quoting. If you change the default, re-run that
309
+ comparison rather than reasoning from parameter counts.
310
+
311
+ `agent_tools.schema.json` is hand-maintained for editor tooling and **not** enforced at runtime —
312
+ same arrangement, and same drift hazard, as `functions.schema.json`.
313
+
314
+ First-run UX: `agent/wizard.py` builds `agent.yaml` on the first interactive run (host → a
315
+ pick-list of tool-capable models → reasoning visibility), re-runnable via `devstuff agent
316
+ --setup`. It configures only those three fields deliberately — a first-run wizard asking about
317
+ `num_ctx` and timeouts would be worse than one asking nothing; everything else keeps its default
318
+ and is hand-editable.
319
+
320
+ Not yet built: an `add` wizard for agent tools, and `catalog import`/`export` for them.
321
+
322
+ ## Key design decisions (don't relitigate these)
323
+
324
+ - **uv owns Python provisioning.** The bash wrapper only guarantees `uv` is present; Python
325
+ version and virtualenv management is delegated entirely to `uv run`.
326
+ - **Catalogs are the source of truth, not Python classes.** There is deliberately no per-tool
327
+ subclass — everything is `GenericTool` driven by YAML data, so adding a tool is a data change.
328
+ - **`install()`/`remove()` raise, they don't return status codes.** No `InstallResult` enum;
329
+ callers catch `RuntimeError`/`CalledProcessError`.
330
+ - **Invalid catalogs fail loudly at load time** — malformed YAML, unsupported version, unknown
331
+ fields, bad `requires` all raise `CatalogError` immediately rather than silently degrading.
332
+ - **Custom install/remove scripts are plain strings**, written to a temp file at run time, so
333
+ `bash` gets full script-parsing fidelity instead of `bash -c "..."` string quoting problems.
334
+ - **The agent's safety boundary is the workspace root, enforced in code.** Not a sandbox
335
+ technology (bubblewrap/firejail) and not model instructions — `Path.resolve()` containment plus
336
+ a command denylist, both unit-tested. Chosen so it works with zero new dependencies and fails
337
+ closed; if you want stronger isolation, add it *around* this, not instead of it.
338
+ - **No new runtime dependencies for the agent.** The Ollama transport is stdlib `urllib` against
339
+ `/api/chat`; the REPL uses `prompt_toolkit`, already vendored via questionary. devstuff is a
340
+ globally installed CLI, so every dependency is a cost paid by users who never run `agent`.
341
+ - **Configuration is Python, installation is YAML.** The no-per-tool-code rule covers *install
342
+ mechanisms*, which generalise; per-tool config formats don't. `configure/` is a registry of
343
+ Python wizards on purpose — see the section above before trying to fold it into `tools.yaml`.
344
+ - **A config wizard previews with the real binary, not an approximation.** `starship prompt`
345
+ against a temp config in a throwaway project is the source of truth; the offline renderer is a
346
+ labelled fallback for when the tool isn't installed (and is what unit tests exercise).
347
+ - **Functions get a parallel catalog/registry instead of extending `GenericTool`.** The
348
+ schemas diverge enough (no `requires` inference, a `params` list, no install/remove
349
+ lifecycle) that folding them into the tool catalog would be lossy; some duplication with
350
+ `catalog.py`/`registry.py` is an accepted tradeoff over a forced shared abstraction.