devstuff 1.15.0__tar.gz → 1.16.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 (85) hide show
  1. {devstuff-1.15.0 → devstuff-1.16.0}/CHANGELOG.md +22 -0
  2. {devstuff-1.15.0 → devstuff-1.16.0}/CLAUDE.md +79 -0
  3. {devstuff-1.15.0 → devstuff-1.16.0}/PKG-INFO +176 -2
  4. {devstuff-1.15.0 → devstuff-1.16.0}/README.md +175 -1
  5. devstuff-1.16.0/dev/Makefile +60 -0
  6. devstuff-1.16.0/docs/specs/README.md +41 -0
  7. devstuff-1.16.0/docs/specs/agent/development-plan.md +194 -0
  8. devstuff-1.16.0/docs/specs/agent/specifications.md +204 -0
  9. devstuff-1.16.0/docs/specs/agent/stack-decisions.md +177 -0
  10. {devstuff-1.15.0 → devstuff-1.16.0}/pyproject.toml +1 -1
  11. devstuff-1.16.0/src/dev_setup/agent/__init__.py +6 -0
  12. devstuff-1.16.0/src/dev_setup/agent/approval.py +102 -0
  13. devstuff-1.16.0/src/dev_setup/agent/bridges.py +125 -0
  14. devstuff-1.16.0/src/dev_setup/agent/catalog.py +158 -0
  15. devstuff-1.16.0/src/dev_setup/agent/completion.py +50 -0
  16. devstuff-1.16.0/src/dev_setup/agent/config.py +170 -0
  17. devstuff-1.16.0/src/dev_setup/agent/loop.py +129 -0
  18. devstuff-1.16.0/src/dev_setup/agent/ollama.py +243 -0
  19. devstuff-1.16.0/src/dev_setup/agent/preflight.py +98 -0
  20. devstuff-1.16.0/src/dev_setup/agent/primitives.py +101 -0
  21. devstuff-1.16.0/src/dev_setup/agent/registry.py +156 -0
  22. devstuff-1.16.0/src/dev_setup/agent/sandbox.py +286 -0
  23. devstuff-1.16.0/src/dev_setup/agent/session.py +334 -0
  24. devstuff-1.16.0/src/dev_setup/agent/transcript.py +52 -0
  25. devstuff-1.16.0/src/dev_setup/agent/wizard.py +104 -0
  26. devstuff-1.16.0/src/dev_setup/agent_tools.schema.json +112 -0
  27. devstuff-1.16.0/src/dev_setup/agent_tools.yaml +145 -0
  28. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/cli.py +2 -0
  29. devstuff-1.16.0/src/dev_setup/commands/agent_cmd.py +143 -0
  30. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/help_cmd.py +4 -0
  31. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/function_runner.py +18 -2
  32. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/tools.yaml +20 -5
  33. devstuff-1.16.0/tests/integration/test_agent_smoke.py +107 -0
  34. devstuff-1.16.0/tests/integration/test_agent_tui.py +92 -0
  35. devstuff-1.16.0/tests/test_agent.py +379 -0
  36. devstuff-1.16.0/tests/test_agent_completion.py +197 -0
  37. devstuff-1.16.0/tests/test_agent_loop.py +368 -0
  38. devstuff-1.16.0/tests/test_agent_sandbox.py +348 -0
  39. devstuff-1.16.0/tests/test_agent_tools.py +417 -0
  40. devstuff-1.16.0/tests/test_agent_wizard.py +205 -0
  41. {devstuff-1.15.0 → devstuff-1.16.0}/tests/test_functions.py +29 -0
  42. {devstuff-1.15.0 → devstuff-1.16.0}/uv.lock +1 -1
  43. devstuff-1.15.0/dev/Makefile +0 -39
  44. {devstuff-1.15.0 → devstuff-1.16.0}/.claude/skills/run-stuff/SKILL.md +0 -0
  45. {devstuff-1.15.0 → devstuff-1.16.0}/.claude/skills/run-stuff/smoke.sh +0 -0
  46. {devstuff-1.15.0 → devstuff-1.16.0}/.github/workflows/bump.yml +0 -0
  47. {devstuff-1.15.0 → devstuff-1.16.0}/.github/workflows/ci.yml +0 -0
  48. {devstuff-1.15.0 → devstuff-1.16.0}/.github/workflows/publish.yml +0 -0
  49. {devstuff-1.15.0 → devstuff-1.16.0}/.github/workflows/test-installs.yml +0 -0
  50. {devstuff-1.15.0 → devstuff-1.16.0}/.gitignore +0 -0
  51. {devstuff-1.15.0 → devstuff-1.16.0}/dev/Dockerfile +0 -0
  52. {devstuff-1.15.0 → devstuff-1.16.0}/dev/Dockerfile.ci +0 -0
  53. {devstuff-1.15.0 → devstuff-1.16.0}/dev/docker-compose.yml +0 -0
  54. {devstuff-1.15.0 → devstuff-1.16.0}/dev-setup +0 -0
  55. {devstuff-1.15.0 → devstuff-1.16.0}/install.sh +0 -0
  56. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/__init__.py +0 -0
  57. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/__main__.py +0 -0
  58. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/base.py +0 -0
  59. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/catalog.py +0 -0
  60. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/__init__.py +0 -0
  61. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/add_cmd.py +0 -0
  62. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/catalog_cmd.py +0 -0
  63. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/delete_cmd.py +0 -0
  64. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/docs_cmd.py +0 -0
  65. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/functions_cmd.py +0 -0
  66. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/install_cmd.py +0 -0
  67. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/list_cmd.py +0 -0
  68. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/remove_cmd.py +0 -0
  69. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/run_cmd.py +0 -0
  70. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/skills_cmd.py +0 -0
  71. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/commands/update_cmd.py +0 -0
  72. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/functions.schema.json +0 -0
  73. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/functions.yaml +0 -0
  74. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/functions_catalog.py +0 -0
  75. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/functions_registry.py +0 -0
  76. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/generic.py +0 -0
  77. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/registry.py +0 -0
  78. {devstuff-1.15.0 → devstuff-1.16.0}/src/dev_setup/ui.py +0 -0
  79. {devstuff-1.15.0 → devstuff-1.16.0}/tests/__init__.py +0 -0
  80. {devstuff-1.15.0 → devstuff-1.16.0}/tests/integration/__init__.py +0 -0
  81. {devstuff-1.15.0 → devstuff-1.16.0}/tests/integration/conftest.py +0 -0
  82. {devstuff-1.15.0 → devstuff-1.16.0}/tests/integration/test_tools.py +0 -0
  83. {devstuff-1.15.0 → devstuff-1.16.0}/tests/test_catalog.py +0 -0
  84. {devstuff-1.15.0 → devstuff-1.16.0}/tests/test_generic.py +0 -0
  85. {devstuff-1.15.0 → devstuff-1.16.0}/tests/test_registry.py +0 -0
@@ -1,3 +1,25 @@
1
+ ## v1.16.0 (2026-07-24)
2
+
3
+ ### Feat
4
+
5
+ - **agent**: first-run setup wizard for agent configuration
6
+ - **agent**: multi-line input and slash completion in the REPL
7
+ - **agent**: milestone 4 — transcripts, /history, docs, schema
8
+ - **agent**: complete milestone 2 — tools, sandbox denylist, approval loop
9
+ - **agent**: add workspace sandbox, launch guard, and smoke suite
10
+ - **agent**: add ollama-backed agent scaffold (milestone 1)
11
+
12
+ ### Fix
13
+
14
+ - **agent**: correct argument mapping and output capture for function tools
15
+
16
+ ## v1.15.1 (2026-07-20)
17
+
18
+ ### Fix
19
+
20
+ - make aws update idempotent (pass --update over existing install)
21
+ - repair broken update paths for llm-checker and ruby
22
+
1
23
  ## v1.15.0 (2026-07-18)
2
24
 
3
25
  ### Feat
@@ -167,6 +167,78 @@ non-interactive `bash <tmpfile>`, so `~/.bashrc`/nvm's shell init never runs on
167
167
  Not yet built: an `add` wizard and `catalog import`/`export` for functions, analogous to the
168
168
  ones tools already have.
169
169
 
170
+ ## Specs (`docs/specs/`)
171
+
172
+ Design documents live in `docs/specs/<feature>/` — `specifications.md` (numbered, testable
173
+ requirements), `stack-decisions.md` (choices *and* rejected alternatives with reasons), and
174
+ `development-plan.md` (milestones, testing strategy, risks). See `docs/specs/README.md` for the
175
+ conventions.
176
+
177
+ When working on a feature that has a spec, read it first and **keep it current in the same PR** —
178
+ a spec that no longer matches the code is worse than no spec. Record resolved open questions with
179
+ a date and the answer rather than deleting them, and when a live finding contradicts a
180
+ requirement, update the requirement and note what was learned. New features of any size should
181
+ get a spec directory before implementation starts.
182
+
183
+ ## The agent (`devstuff agent`) — a third catalog subsystem
184
+
185
+ `src/dev_setup/agent/` + `agent_tools.yaml` is an interactive session where a local Ollama model
186
+ calls devstuff's tools plus a workspace-scoped filesystem/shell kit. It follows the same
187
+ catalog-driven shape as tools and functions: `agent/catalog.py` validates `agent_tools.yaml`
188
+ (bundled → user override), `agent/registry.py` turns it into `AgentTool` objects, and
189
+ `agent/primitives.py` dispatches by `impl` through a `_PRIMITIVES` dict — the same
190
+ strategy-dispatch pattern as `_INSTALLERS` in `generic.py`.
191
+
192
+ **Adding an agent tool**: if it bridges to something that already exists (a catalog tool, a
193
+ `functions.yaml` entry), it is a pure YAML edit — `impl: catalog` or `impl: function` plus a
194
+ `target`. Only a genuinely new mechanism needs a new `impl: primitive` callable registered in
195
+ `_PRIMITIVES`. `type: script` functions are auto-exposed as `fn_<key>` tools with no edit at all.
196
+
197
+ **Security invariants — do not weaken these without deliberate thought:**
198
+ - `Workspace.resolve()` in `agent/sandbox.py` is the *only* thing standing between the model and
199
+ the filesystem. It resolves symlinks and `..` **before** the containment check; reordering that
200
+ reintroduces a symlink escape. Prompt instructions are not a control and never will be.
201
+ - The command denylist (`check_command`) runs **before** any confirmation prompt and is
202
+ deliberately not disabled by `--yolo`. The prompt is a human attention filter; attention
203
+ degrades over a session, the denylist does not.
204
+ - Credential dirs are blocked for **read** as well as write — exfiltrating an SSH key into a model
205
+ context is as bad as overwriting one. `~/.config/dev-setup` is readable but not writable, so
206
+ the agent cannot author catalogs (FR-14a).
207
+ - `assess()` (the launch guard) is advisory UX, not a control. Keep that distinction in comments;
208
+ the risk is a future reader mistaking a warning for enforcement.
209
+
210
+ **Everything in the loop returns errors to the model rather than raising.** Unknown tool, bad
211
+ arguments, sandbox refusal, a crashing tool — all become `role: tool` messages so the agent can
212
+ re-plan. A malformed tool call must never end a session. `max_iterations` is what stops a runaway.
213
+
214
+ **`cd` is a tool, not a shell command**, for the same reason `shell-eval` functions exist: each
215
+ `run_command` is its own subprocess, so a shell `cd` evaporates on exit. And `shell-eval`
216
+ functions are excluded from the toolbox entirely — they exist to mutate the calling shell, which
217
+ an agent subprocess has no way to do (mirroring the guard in `run_cmd.py`).
218
+
219
+ **Ollama response-shape handling all lives in `ollama.parse_message()`.** Builds differ on
220
+ whether reasoning arrives in `message.thinking` or as inline `<think>` tags in `content`, and
221
+ whether tool calls arrive in `tool_calls` or as JSON inside `content`. Think-stripping must stay
222
+ *ahead* of the content-JSON fallback, or a reasoning preamble hides the tool call. Keep new
223
+ quirks in that one function.
224
+
225
+ **Model choice is measured, not assumed.** `ollama show` reports a `capabilities` array;
226
+ preflight requires `tools` in it. The default (`gemma4:latest`) was picked by running the same
227
+ scaffolding prompt across local models — lfm2.5 had `write_file` available and still shelled out
228
+ to `echo >`, corrupting the content through shell quoting. If you change the default, re-run that
229
+ comparison rather than reasoning from parameter counts.
230
+
231
+ `agent_tools.schema.json` is hand-maintained for editor tooling and **not** enforced at runtime —
232
+ same arrangement, and same drift hazard, as `functions.schema.json`.
233
+
234
+ First-run UX: `agent/wizard.py` builds `agent.yaml` on the first interactive run (host → a
235
+ pick-list of tool-capable models → reasoning visibility), re-runnable via `devstuff agent
236
+ --setup`. It configures only those three fields deliberately — a first-run wizard asking about
237
+ `num_ctx` and timeouts would be worse than one asking nothing; everything else keeps its default
238
+ and is hand-editable.
239
+
240
+ Not yet built: an `add` wizard for agent tools, and `catalog import`/`export` for them.
241
+
170
242
  ## Key design decisions (don't relitigate these)
171
243
 
172
244
  - **uv owns Python provisioning.** The bash wrapper only guarantees `uv` is present; Python
@@ -179,6 +251,13 @@ ones tools already have.
179
251
  fields, bad `requires` all raise `CatalogError` immediately rather than silently degrading.
180
252
  - **Custom install/remove scripts are plain strings**, written to a temp file at run time, so
181
253
  `bash` gets full script-parsing fidelity instead of `bash -c "..."` string quoting problems.
254
+ - **The agent's safety boundary is the workspace root, enforced in code.** Not a sandbox
255
+ technology (bubblewrap/firejail) and not model instructions — `Path.resolve()` containment plus
256
+ a command denylist, both unit-tested. Chosen so it works with zero new dependencies and fails
257
+ closed; if you want stronger isolation, add it *around* this, not instead of it.
258
+ - **No new runtime dependencies for the agent.** The Ollama transport is stdlib `urllib` against
259
+ `/api/chat`; the REPL uses `prompt_toolkit`, already vendored via questionary. devstuff is a
260
+ globally installed CLI, so every dependency is a cost paid by users who never run `agent`.
182
261
  - **Functions get a parallel catalog/registry instead of extending `GenericTool`.** The
183
262
  schemas diverge enough (no `requires` inference, a `params` list, no install/remove
184
263
  lifecycle) that folding them into the tool catalog would be lossy; some duplication with
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devstuff
3
- Version: 1.15.0
3
+ Version: 1.16.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>
@@ -291,6 +291,160 @@ confirmation.
291
291
 
292
292
  ---
293
293
 
294
+ ## Agent
295
+
296
+ `devstuff agent` opens an interactive session with a **local** model (via [Ollama](https://ollama.com))
297
+ that can call devstuff's own tools plus a workspace-scoped filesystem/shell kit. Nothing leaves
298
+ the machine, there are no API keys, and it works offline.
299
+
300
+ ```bash
301
+ devstuff agent # interactive REPL
302
+ devstuff agent --setup # (re)run the configuration wizard
303
+ devstuff agent --dir ~/projects # skip the workspace prompt
304
+ devstuff agent --model granite4.1:8b # override the configured model
305
+ devstuff agent --print "which node tools do I have?" # one-shot, non-interactive
306
+ ```
307
+
308
+ ```
309
+ you ❯ create a python project called xyz-project with a hello world main.py
310
+
311
+ ↳ run_command(command='mkdir xyz-project')
312
+
313
+ ╭─ run in ./xyz-project ──────────────────────╮
314
+ │ mkdir xyz-project │
315
+ ╰─────────────────────────────────────────────╯
316
+ ? Run run_command? Yes / No / Always allow this tool
317
+ ```
318
+
319
+ ### Setup
320
+
321
+ First, make sure a tool-capable model is available:
322
+
323
+ ```bash
324
+ devstuff install ollama
325
+ ollama pull gemma4 # or any model reporting the `tools` capability
326
+ ```
327
+
328
+ Then the **first time you run `devstuff agent`** with no configuration, a wizard walks you
329
+ through it — Ollama host, then a pick-list of your locally available tool-capable models, then
330
+ whether to show the model's reasoning. It writes `~/.config/dev-setup/agent.yaml` and continues
331
+ into the session. Re-run it any time with `devstuff agent --setup`.
332
+
333
+ The wizard only lists models that report the `tools` capability (`ollama show <model>` shows it
334
+ under Capabilities), so you can't accidentally pick one that can't call tools. `devstuff agent`
335
+ re-verifies this at startup regardless, and tells you which local models qualify if the
336
+ configured one doesn't.
337
+
338
+ The wizard is skipped when you pass `--model`/`--host` (you're already steering) or run
339
+ non-interactively (`--print`), which fall back to built-in defaults.
340
+
341
+ ### Safety model
342
+
343
+ The workspace root, chosen at launch (prompted, defaulting to the current directory), is the
344
+ boundary:
345
+
346
+ - **Path containment** — every path a tool touches is resolved (collapsing `..` and following
347
+ symlinks) and must land inside the workspace root. A symlink planted inside the workspace is
348
+ not a way out.
349
+ - **Protected paths** — `~/.ssh`, `~/.aws`, `~/.gnupg` and `~/.config/gh` are refused for read
350
+ *and* write even if the workspace root contains them. `~/.config/dev-setup` is readable but
351
+ never writable: catalog authoring stays a human action.
352
+ - **Confirmation** — every mutating tool call shows the exact command, or a unified diff for
353
+ `write_file`, and waits for yes / no / always-this-session. Read-only calls run silently.
354
+ - **Denylist** — `sudo`, pipe-to-shell installers, catastrophic deletes, disk and service
355
+ commands, and redirects out of the workspace are refused before any prompt is shown, and are
356
+ **not** enabled by `--yolo`.
357
+ - **Launch guard** — warns before handing the agent `$HOME`, a system directory, or a git repo
358
+ with uncommitted changes.
359
+
360
+ `--yolo` skips confirmations for a session; the denylist and path containment still apply.
361
+ In `--print` mode without `--yolo`, mutating calls are refused rather than auto-approved, so a
362
+ scripted invocation cannot become an unattended agent with write access.
363
+
364
+ ### Configuration
365
+
366
+ `~/.config/dev-setup/agent.yaml` (all fields optional):
367
+
368
+ ```yaml
369
+ version: 1
370
+ model: gemma4:latest
371
+ host: http://localhost:11434 # a remote daemon works; the local binary is then not required
372
+ temperature: 0.2
373
+ num_ctx: 16384
374
+ think: false # true renders the model's reasoning, dimmed
375
+ max_iterations: 12 # tool calls per turn before the loop gives up
376
+ request_timeout: 120
377
+ command_timeout: 120
378
+ max_tool_output_bytes: 8000 # tool output is truncated to this before going back to the model
379
+ auto_approve: [] # tool keys that never ask, e.g. ["write_file"]
380
+ deny_patterns: [] # extra regexes refused on top of the built-in denylist
381
+ ```
382
+
383
+ ### Tools
384
+
385
+ | Tool | Mutating | Purpose |
386
+ |------|----------|---------|
387
+ | `read_file` | | Read a UTF-8 file in the workspace |
388
+ | `write_file` | ! | Create or overwrite a file (shows a diff) |
389
+ | `list_dir` | | List a directory |
390
+ | `cd` | | Move the working directory used by later calls |
391
+ | `run_command` | ! | Run a shell command in the workspace |
392
+ | `list_tools` | | List the devstuff catalog with install state |
393
+ | `search_catalog` | | Find a catalog tool by name or description |
394
+ | `tool_info` | | Details for one catalog tool |
395
+ | `install_tool` | ! | Install a catalog tool (handles sudo itself) |
396
+ | `fn_<key>` | ! | Every `type: script` entry in `functions.yaml`, exposed automatically |
397
+
398
+ `cd` is a tool rather than a shell command because each `run_command` is its own subprocess — a
399
+ shell `cd` would evaporate when it exits. `shell-eval` functions are deliberately excluded: they
400
+ exist to mutate the calling shell, which a subprocess cannot do.
401
+
402
+ ### agent_tools.yaml
403
+
404
+ The toolbox is a catalog, like everything else. Bundled at `src/dev_setup/agent_tools.yaml`,
405
+ user overrides at `~/.config/dev-setup/agent_tools.yaml`, same merge precedence as `tools.yaml`.
406
+
407
+ ```yaml
408
+ version: 1
409
+ expose_functions: true
410
+ tools:
411
+ count_lines:
412
+ name: Count Lines
413
+ description: Count the lines in a file. Use this instead of reading a large file.
414
+ impl: primitive # primitive | catalog | function
415
+ mutating: false
416
+ params:
417
+ - name: path
418
+ type: string
419
+ description: Path to the file, relative to the current directory.
420
+ required: true
421
+ ```
422
+
423
+ `impl: primitive` dispatches to a callable in `agent/primitives.py` keyed by the tool key (adding
424
+ a new one is a code change). `impl: catalog` and `impl: function` bridge to the tool registry and
425
+ `functions.yaml` respectively via `target`, and need no code. `src/dev_setup/agent_tools.schema.json`
426
+ documents every field for editor autocomplete; it is not enforced at runtime.
427
+
428
+ ### Session state
429
+
430
+ - Prompt history: `~/.local/share/dev-setup/agent/history`
431
+ - Transcripts: `~/.local/share/dev-setup/agent/transcripts/<timestamp>.json`, written after every
432
+ turn so a session that ends in a crash is still readable. `/history` shows the current session
433
+ and the transcript path.
434
+
435
+ ### Using the prompt
436
+
437
+ - **Enter** sends. **Alt+Enter** (or **Ctrl-J**) inserts a newline, for pasting or writing
438
+ multi-line instructions.
439
+ - **Type `/`** for a completion menu of session commands *and* every available tool, mutating
440
+ ones flagged with `!`. **Tab** cycles, **Enter** takes the highlighted entry.
441
+ - `/<tool>` describes a tool and its parameters, e.g. `/write_file`. It does not run it — the
442
+ agent decides when tools run, so there is no back door around the confirmation flow.
443
+
444
+ In-session commands: `/tools`, `/history`, `/cwd`, `/model`, `/reset`, `/help`, `/exit`.
445
+
446
+ ---
447
+
294
448
  ## Functions/Scripts
295
449
 
296
450
  Reusable shell functions/snippets, tracked in a separate catalog from installable tools
@@ -586,6 +740,21 @@ dev-setup-py/
586
740
  ├── functions_registry.py # Loads bundled + user YAML into the live function registry
587
741
  ├── function_runner.py # Param resolution + script/eval/bashrc rendering & execution
588
742
  ├── functions.yaml # Bundled built-in function catalog
743
+ ├── agent_tools.yaml # Bundled agent tool catalog
744
+ ├── agent/ # Local-model agent (see "Agent" above)
745
+ │ ├── config.py # agent.yaml load/validate/save
746
+ │ ├── wizard.py # first-run setup wizard (devstuff agent --setup)
747
+ │ ├── ollama.py # stdlib-urllib client for /api/chat, /api/tags, /api/show
748
+ │ ├── preflight.py # installed / reachable / pulled / tool-capable checks
749
+ │ ├── sandbox.py # Workspace containment + command denylist + launch guard
750
+ │ ├── catalog.py # agent_tools.yaml load/validate/merge
751
+ │ ├── registry.py # AgentTool -> Ollama tool schema; function auto-exposure
752
+ │ ├── primitives.py # _PRIMITIVES dispatch: read_file/write_file/list_dir/cd/run_command
753
+ │ ├── bridges.py # catalog + functions.yaml bridges
754
+ │ ├── approval.py # confirmation prompts, unified diffs
755
+ │ ├── loop.py # tool-calling loop
756
+ │ ├── transcript.py # per-session JSON transcript
757
+ │ └── session.py # REPL, slash commands
589
758
  ├── ui.py # Rich console helpers, questionary wrappers, styled prompts
590
759
  ├── commands/
591
760
  │ ├── list_cmd.py
@@ -596,7 +765,12 @@ dev-setup-py/
596
765
  │ ├── delete_cmd.py
597
766
  │ ├── catalog_cmd.py
598
767
  │ ├── run_cmd.py
599
- └── functions_cmd.py
768
+ ├── functions_cmd.py
769
+ │ ├── skills_cmd.py
770
+ │ └── agent_cmd.py
771
+ └── docs/
772
+ └── specs/ # Design docs per feature (see docs/specs/README.md)
773
+ └── agent/
600
774
  ```
601
775
 
602
776
  ### Adding a new built-in tool
@@ -264,6 +264,160 @@ confirmation.
264
264
 
265
265
  ---
266
266
 
267
+ ## Agent
268
+
269
+ `devstuff agent` opens an interactive session with a **local** model (via [Ollama](https://ollama.com))
270
+ that can call devstuff's own tools plus a workspace-scoped filesystem/shell kit. Nothing leaves
271
+ the machine, there are no API keys, and it works offline.
272
+
273
+ ```bash
274
+ devstuff agent # interactive REPL
275
+ devstuff agent --setup # (re)run the configuration wizard
276
+ devstuff agent --dir ~/projects # skip the workspace prompt
277
+ devstuff agent --model granite4.1:8b # override the configured model
278
+ devstuff agent --print "which node tools do I have?" # one-shot, non-interactive
279
+ ```
280
+
281
+ ```
282
+ you ❯ create a python project called xyz-project with a hello world main.py
283
+
284
+ ↳ run_command(command='mkdir xyz-project')
285
+
286
+ ╭─ run in ./xyz-project ──────────────────────╮
287
+ │ mkdir xyz-project │
288
+ ╰─────────────────────────────────────────────╯
289
+ ? Run run_command? Yes / No / Always allow this tool
290
+ ```
291
+
292
+ ### Setup
293
+
294
+ First, make sure a tool-capable model is available:
295
+
296
+ ```bash
297
+ devstuff install ollama
298
+ ollama pull gemma4 # or any model reporting the `tools` capability
299
+ ```
300
+
301
+ Then the **first time you run `devstuff agent`** with no configuration, a wizard walks you
302
+ through it — Ollama host, then a pick-list of your locally available tool-capable models, then
303
+ whether to show the model's reasoning. It writes `~/.config/dev-setup/agent.yaml` and continues
304
+ into the session. Re-run it any time with `devstuff agent --setup`.
305
+
306
+ The wizard only lists models that report the `tools` capability (`ollama show <model>` shows it
307
+ under Capabilities), so you can't accidentally pick one that can't call tools. `devstuff agent`
308
+ re-verifies this at startup regardless, and tells you which local models qualify if the
309
+ configured one doesn't.
310
+
311
+ The wizard is skipped when you pass `--model`/`--host` (you're already steering) or run
312
+ non-interactively (`--print`), which fall back to built-in defaults.
313
+
314
+ ### Safety model
315
+
316
+ The workspace root, chosen at launch (prompted, defaulting to the current directory), is the
317
+ boundary:
318
+
319
+ - **Path containment** — every path a tool touches is resolved (collapsing `..` and following
320
+ symlinks) and must land inside the workspace root. A symlink planted inside the workspace is
321
+ not a way out.
322
+ - **Protected paths** — `~/.ssh`, `~/.aws`, `~/.gnupg` and `~/.config/gh` are refused for read
323
+ *and* write even if the workspace root contains them. `~/.config/dev-setup` is readable but
324
+ never writable: catalog authoring stays a human action.
325
+ - **Confirmation** — every mutating tool call shows the exact command, or a unified diff for
326
+ `write_file`, and waits for yes / no / always-this-session. Read-only calls run silently.
327
+ - **Denylist** — `sudo`, pipe-to-shell installers, catastrophic deletes, disk and service
328
+ commands, and redirects out of the workspace are refused before any prompt is shown, and are
329
+ **not** enabled by `--yolo`.
330
+ - **Launch guard** — warns before handing the agent `$HOME`, a system directory, or a git repo
331
+ with uncommitted changes.
332
+
333
+ `--yolo` skips confirmations for a session; the denylist and path containment still apply.
334
+ In `--print` mode without `--yolo`, mutating calls are refused rather than auto-approved, so a
335
+ scripted invocation cannot become an unattended agent with write access.
336
+
337
+ ### Configuration
338
+
339
+ `~/.config/dev-setup/agent.yaml` (all fields optional):
340
+
341
+ ```yaml
342
+ version: 1
343
+ model: gemma4:latest
344
+ host: http://localhost:11434 # a remote daemon works; the local binary is then not required
345
+ temperature: 0.2
346
+ num_ctx: 16384
347
+ think: false # true renders the model's reasoning, dimmed
348
+ max_iterations: 12 # tool calls per turn before the loop gives up
349
+ request_timeout: 120
350
+ command_timeout: 120
351
+ max_tool_output_bytes: 8000 # tool output is truncated to this before going back to the model
352
+ auto_approve: [] # tool keys that never ask, e.g. ["write_file"]
353
+ deny_patterns: [] # extra regexes refused on top of the built-in denylist
354
+ ```
355
+
356
+ ### Tools
357
+
358
+ | Tool | Mutating | Purpose |
359
+ |------|----------|---------|
360
+ | `read_file` | | Read a UTF-8 file in the workspace |
361
+ | `write_file` | ! | Create or overwrite a file (shows a diff) |
362
+ | `list_dir` | | List a directory |
363
+ | `cd` | | Move the working directory used by later calls |
364
+ | `run_command` | ! | Run a shell command in the workspace |
365
+ | `list_tools` | | List the devstuff catalog with install state |
366
+ | `search_catalog` | | Find a catalog tool by name or description |
367
+ | `tool_info` | | Details for one catalog tool |
368
+ | `install_tool` | ! | Install a catalog tool (handles sudo itself) |
369
+ | `fn_<key>` | ! | Every `type: script` entry in `functions.yaml`, exposed automatically |
370
+
371
+ `cd` is a tool rather than a shell command because each `run_command` is its own subprocess — a
372
+ shell `cd` would evaporate when it exits. `shell-eval` functions are deliberately excluded: they
373
+ exist to mutate the calling shell, which a subprocess cannot do.
374
+
375
+ ### agent_tools.yaml
376
+
377
+ The toolbox is a catalog, like everything else. Bundled at `src/dev_setup/agent_tools.yaml`,
378
+ user overrides at `~/.config/dev-setup/agent_tools.yaml`, same merge precedence as `tools.yaml`.
379
+
380
+ ```yaml
381
+ version: 1
382
+ expose_functions: true
383
+ tools:
384
+ count_lines:
385
+ name: Count Lines
386
+ description: Count the lines in a file. Use this instead of reading a large file.
387
+ impl: primitive # primitive | catalog | function
388
+ mutating: false
389
+ params:
390
+ - name: path
391
+ type: string
392
+ description: Path to the file, relative to the current directory.
393
+ required: true
394
+ ```
395
+
396
+ `impl: primitive` dispatches to a callable in `agent/primitives.py` keyed by the tool key (adding
397
+ a new one is a code change). `impl: catalog` and `impl: function` bridge to the tool registry and
398
+ `functions.yaml` respectively via `target`, and need no code. `src/dev_setup/agent_tools.schema.json`
399
+ documents every field for editor autocomplete; it is not enforced at runtime.
400
+
401
+ ### Session state
402
+
403
+ - Prompt history: `~/.local/share/dev-setup/agent/history`
404
+ - Transcripts: `~/.local/share/dev-setup/agent/transcripts/<timestamp>.json`, written after every
405
+ turn so a session that ends in a crash is still readable. `/history` shows the current session
406
+ and the transcript path.
407
+
408
+ ### Using the prompt
409
+
410
+ - **Enter** sends. **Alt+Enter** (or **Ctrl-J**) inserts a newline, for pasting or writing
411
+ multi-line instructions.
412
+ - **Type `/`** for a completion menu of session commands *and* every available tool, mutating
413
+ ones flagged with `!`. **Tab** cycles, **Enter** takes the highlighted entry.
414
+ - `/<tool>` describes a tool and its parameters, e.g. `/write_file`. It does not run it — the
415
+ agent decides when tools run, so there is no back door around the confirmation flow.
416
+
417
+ In-session commands: `/tools`, `/history`, `/cwd`, `/model`, `/reset`, `/help`, `/exit`.
418
+
419
+ ---
420
+
267
421
  ## Functions/Scripts
268
422
 
269
423
  Reusable shell functions/snippets, tracked in a separate catalog from installable tools
@@ -559,6 +713,21 @@ dev-setup-py/
559
713
  ├── functions_registry.py # Loads bundled + user YAML into the live function registry
560
714
  ├── function_runner.py # Param resolution + script/eval/bashrc rendering & execution
561
715
  ├── functions.yaml # Bundled built-in function catalog
716
+ ├── agent_tools.yaml # Bundled agent tool catalog
717
+ ├── agent/ # Local-model agent (see "Agent" above)
718
+ │ ├── config.py # agent.yaml load/validate/save
719
+ │ ├── wizard.py # first-run setup wizard (devstuff agent --setup)
720
+ │ ├── ollama.py # stdlib-urllib client for /api/chat, /api/tags, /api/show
721
+ │ ├── preflight.py # installed / reachable / pulled / tool-capable checks
722
+ │ ├── sandbox.py # Workspace containment + command denylist + launch guard
723
+ │ ├── catalog.py # agent_tools.yaml load/validate/merge
724
+ │ ├── registry.py # AgentTool -> Ollama tool schema; function auto-exposure
725
+ │ ├── primitives.py # _PRIMITIVES dispatch: read_file/write_file/list_dir/cd/run_command
726
+ │ ├── bridges.py # catalog + functions.yaml bridges
727
+ │ ├── approval.py # confirmation prompts, unified diffs
728
+ │ ├── loop.py # tool-calling loop
729
+ │ ├── transcript.py # per-session JSON transcript
730
+ │ └── session.py # REPL, slash commands
562
731
  ├── ui.py # Rich console helpers, questionary wrappers, styled prompts
563
732
  ├── commands/
564
733
  │ ├── list_cmd.py
@@ -569,7 +738,12 @@ dev-setup-py/
569
738
  │ ├── delete_cmd.py
570
739
  │ ├── catalog_cmd.py
571
740
  │ ├── run_cmd.py
572
- └── functions_cmd.py
741
+ ├── functions_cmd.py
742
+ │ ├── skills_cmd.py
743
+ │ └── agent_cmd.py
744
+ └── docs/
745
+ └── specs/ # Design docs per feature (see docs/specs/README.md)
746
+ └── agent/
573
747
  ```
574
748
 
575
749
  ### Adding a new built-in tool
@@ -0,0 +1,60 @@
1
+ ROOT := ..
2
+
3
+ # Pass TOOL=<key> to run-tests to test a single tool, e.g. make run-tests TOOL=uv
4
+ # Pass PYTEST_ARGS="..." for arbitrary pytest flags, e.g. make run-tests PYTEST_ARGS="-x"
5
+ TOOL ?=
6
+ PYTEST_ARGS ?=
7
+
8
+ .PHONY: build build-ci run shell run-tests smoke-agent tui-tests clean
9
+
10
+ # Build a fresh wheel from source, then build the Docker image.
11
+ # Clean dist first so the glob in the Dockerfile matches exactly one file.
12
+ build:
13
+ cd $(ROOT) && rm -f dist/*.whl dist/*.tar.gz && uv build
14
+ docker compose build
15
+
16
+ # Build the CI image (sandbox + pytest + integration tests baked in)
17
+ build-ci: build
18
+ docker build -f Dockerfile.ci -t dev-setup-ci ..
19
+
20
+ # Drop into an interactive shell as the 'developer' user
21
+ run: build
22
+ docker compose run --rm sandbox
23
+
24
+ shell: run
25
+
26
+ # Run the tool-install integration tests inside the CI container.
27
+ # Scoped to test_tools.py on purpose: the agent suites are also integration-marked
28
+ # but belong on the host (smoke-agent needs a daemon, tui-tests needs a PTY and
29
+ # realistic timing), and neither has anything to do with installing tools.
30
+ # Examples:
31
+ # make run-tests — run every tool's install test
32
+ # make run-tests TOOL=uv — run only the uv test
33
+ # make run-tests PYTEST_ARGS="-x --tb=long"
34
+ run-tests: build-ci
35
+ $(eval _NODESEL := $(if $(TOOL),tests/integration/test_tools.py::test_install[$(TOOL)],tests/integration/test_tools.py))
36
+ docker run --rm dev-setup-ci \
37
+ pytest $(_NODESEL) -m integration -v --tb=short $(PYTEST_ARGS)
38
+
39
+ # Smoke-test `devstuff agent` against a live Ollama daemon.
40
+ # Runs on the HOST, not in Docker -- the CI image has no daemon, and yours may be
41
+ # on another machine. Skips cleanly if nothing is reachable.
42
+ # make smoke-agent
43
+ # make smoke-agent AGENT_HOST=http://192.168.1.69:11434 AGENT_MODEL=gemma4:latest
44
+ AGENT_HOST ?=
45
+ AGENT_MODEL ?=
46
+ smoke-agent:
47
+ cd $(ROOT) && \
48
+ $(if $(AGENT_HOST),DEVSTUFF_AGENT_HOST=$(AGENT_HOST)) \
49
+ $(if $(AGENT_MODEL),DEVSTUFF_AGENT_MODEL=$(AGENT_MODEL)) \
50
+ uv run pytest tests/integration/test_agent_smoke.py -m integration -v $(PYTEST_ARGS)
51
+
52
+ # Keystroke tests for the agent prompt, driven through a real PTY. Timing-dependent
53
+ # and slow (~16s), so they are excluded from the default suite. No daemon needed.
54
+ tui-tests:
55
+ cd $(ROOT) && uv run pytest tests/integration/test_agent_tui.py -m integration -v $(PYTEST_ARGS)
56
+
57
+ # Remove the built images and named volume
58
+ clean:
59
+ docker compose down --rmi local --volumes 2>/dev/null || true
60
+ docker rmi dev-setup-ci 2>/dev/null || true
@@ -0,0 +1,41 @@
1
+ # Specs
2
+
3
+ Design documents for features and significant changes, one directory per feature.
4
+
5
+ ```
6
+ docs/specs/
7
+ └── <feature>/
8
+ ├── specifications.md # what it does: numbered, testable requirements
9
+ ├── stack-decisions.md # what we chose and what we rejected, with reasons
10
+ └── development-plan.md # milestones, testing strategy, risks
11
+ ```
12
+
13
+ ## Why these exist
14
+
15
+ Git history records *what changed*. These record *what was decided and why* — including the
16
+ options that were rejected, which is the part that gets re-litigated eighteen months later when
17
+ nobody remembers why the obvious approach wasn't taken.
18
+
19
+ They are most useful when they capture:
20
+
21
+ - **Rejected alternatives with their reasons.** "We didn't use the official Ollama package
22
+ because it pulls httpx + pydantic into every install" is worth more than the decision alone.
23
+ - **Findings that changed the design.** A spec that still describes the original guess is worse
24
+ than no spec. When reality contradicts a requirement, update it and say when and why.
25
+ - **Explicit non-goals.** Scope creep is easiest to resist against a written out-of-scope list.
26
+
27
+ ## Conventions
28
+
29
+ - **Keep them current, or delete them.** A stale spec is actively misleading. If a document no
30
+ longer matches the code, fix it in the same PR that broke it.
31
+ - **Mark resolved questions with a date and the answer**, rather than deleting the row — the fact
32
+ that something was once open is information.
33
+ - **Requirements are numbered and testable** (FR-1, NFR-1) so tests and commits can cite them.
34
+ - **Distinguish advisory behaviour from enforced behaviour.** Where a spec describes a security
35
+ property, say which mechanism is the control and which is merely a warning.
36
+
37
+ ## Existing specs
38
+
39
+ | Feature | Status |
40
+ |---------|--------|
41
+ | [`agent/`](agent/) — `devstuff agent`, the local-model agent | Complete (v1) |