Portability + consistency polish (Codex review)

Housekeeping over features, per Codex: consistency and portability now matter
more than another feature.

- driver.scene_summary no longer hardcodes ~/PycharmProjects/.venv/bin/woodshop;
  new driver.woodshop_cmd() resolves the CLI portably (PATH, else `python -m
  woodshop`). Used by the voice/GUI status path.
- scripts/gen_wood_tools.py: CMDFORGE_PY overridable via env; generated tool
  bodies resolve `woodshop` at RUNTIME (shutil.which → python -m woodshop), no
  baked-in local path; file-writing moved under main()/__main__ (was running at
  import); PyYAML declared under dev deps.
- cutlist.py: drop the misleading "+10% waste" label — shopping already uses the
  kerf-aware CutPlan nesting.
- Docs refreshed (README + CLAUDE): real test count, parametric joinery is
  modeled, new cutplan/prices/estimate/inventory/colors modules + GUI windows,
  portable tool regeneration.
- tests: driver path discovery (PATH + module fallback), generated tool bodies
  compile and contain no hardcoded paths. 207 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 21:56:28 -03:00
parent 01c4dee0bc
commit 970b88bc7b
8 changed files with 149 additions and 60 deletions

View File

@ -83,14 +83,26 @@ board-feet / 8' sticks).
Parts have full 3D orientation (`yaw_deg`/`tilt_deg`/`roll_deg`) so legs and Parts have full 3D orientation (`yaw_deg`/`tilt_deg`/`roll_deg`) so legs and
uprights stand vertically. Parts can be referred to by id (`p1`) or by a name uprights stand vertically. Parts can be referred to by id (`p1`) or by a name
set with `rename`. The cut list (`cutlist.py`) reports board-feet and an 8'-stick set with `rename`. Each part also carries `material` + `finish` (raw/sanded/
shopping estimate. clear/stain/paint) + `finish_color`, which drive the viewer color and cost.
**Shop output & inventory subsystems** (all deterministic; AI only narrates):
`cutplan.py` is the keystone — kerf-aware cutting-stock nesting into a JSON-
serializable `CutPlan` (rough-vs-final sanding allowance, batch quantity, owned-
offcut reuse, grouped by (stock, material)). `prices.py` = editable Kent-NB price
book + material-aware cost; `estimate.py` = full quote (consumables + labor +
suggested price); `inventory.py` = shop-wide event-sourced stock/offcut/build
ledger; `instructions.py`/`jigs.py` = build steps + jig suggestions. The GUI
**Cut List & BOM** window (`gui/bom_window.py`) and **Inventory** window
(`gui/inventory_window.py`) render these. `cutlist.py` is a quick text summary.
### CmdForge tools (the documented command vocabulary) ### CmdForge tools (the documented command vocabulary)
`wood-place`, `wood-join`, `wood-sand`, `wood-delete`, `wood-undo` live in `wood-place`, `wood-join`, `wood-sand`, `wood-delete`, `wood-undo` live in
`~/.cmdforge/<name>/` and wrap the `woodshop` CLI. Regenerate them with `~/.cmdforge/<name>/` and wrap the `woodshop` CLI. Regenerate them with
`/tmp/gen_wood_tools.py` (kept in the repo plan) if their schemas change. The `python scripts/gen_wood_tools.py` if their schemas change (set `CMDFORGE_PY` to
point at your cmdforge interpreter; needs PyYAML). The generated wrappers resolve
`woodshop` at runtime (PATH, else `python -m woodshop`), so they're portable. The
arg descriptions ARE the LLM's documentation, so keep them clear. arg descriptions ARE the LLM's documentation, so keep them clear.
### Setup ### Setup
@ -98,7 +110,7 @@ arg descriptions ARE the LLM's documentation, so keep them clear.
```bash ```bash
python3 -m venv .venv && source .venv/bin/activate python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[viewer,dev]" # viewer extra pulls build123d + pyvista pip install -e ".[viewer,dev]" # viewer extra pulls build123d + pyvista
pytest # 25 tests pytest # 200+ tests
``` ```
### Known limitations / next steps ### Known limitations / next steps

View File

@ -96,28 +96,37 @@ The active scene lives at `$WOODSHOP_SCENE` or
## Development ## Development
```bash ```bash
pytest # 41 tests pytest # 200+ tests
``` ```
Key modules: Key modules:
| Module | Role | | Module | Role |
|--------|------| |--------|------|
| `scene.py` | Part/Joint/Scene model, operations, undo, persistence | | `scene.py` | Part/Joint/Connection/Feature/Scene model, ops, undo, persistence |
| `lumber.py` | nominal → actual dimensional lumber table | | `lumber.py` | nominal → actual dimensions, material colors/defaults |
| `colors.py` | color name → hex + lightness blends for the viewer |
| `units.py` | parse "6 ft" / "3 ft 6 in" / "-2 ft" → inches | | `units.py` | parse "6 ft" / "3 ft 6 in" / "-2 ft" → inches |
| `cli.py` | the `woodshop` command | | `cli.py` | the `woodshop` command |
| `geometry.py` | build123d solids + STL/STEP export | | `geometry.py` | build123d solids (incl. joinery booleans) + STL/STEP export |
| `cutlist.py` | cut list, board-feet, shopping estimate | | `cutlist.py` | quick cut list / board-feet / shopping summary |
| `cutplan.py` | the deterministic keystone: kerf-aware nesting, rough/final, batch, offcut reuse |
| `prices.py` | editable price book (Kent NB) + material-aware cost estimate |
| `estimate.py` | project quote: consumables + labor + suggested selling price |
| `inventory.py` | shop-wide event-sourced stock / offcut / build ledger |
| `instructions.py` / `jigs.py` | deterministic build steps & jig suggestions |
| `viewer.py` | live pyvista 3D viewport (`woodshop-view`) | | `viewer.py` | live pyvista 3D viewport (`woodshop-view`) |
| `driver.py` | conversational loop (`woodshop-talk`) | | `driver.py` | conversational loop (`woodshop-talk`) |
| `gui/` | the unified PySide6 studio (`woodshop-gui`), incl. the Cut List & BOM window |
| `scripts/gen_wood_tools.py` | (re)generate the `wood-*` CmdForge tools | | `scripts/gen_wood_tools.py` | (re)generate the `wood-*` CmdForge tools |
### Known limitations ### Known limitations
- Joins are flush butt joints: B's end sits against A's face and B aligns to - **Joinery** is parametric (tenon/mortise/dado/rabbet/hole/slot/chamfer as
A's reference corner (tops level + one side flush), so mixed-size boards line build123d boolean ops, with connections/assemblies); what's *not* modeled is
up. Joinery *cuts* (mortise/tenon, lap, pocket holes) aren't modeled yet. joinery-fit compensation for material lost to sanding, and lap/pocket-hole
presets. Boards still attach as flush butt joints unless you add features.
- Render is flat colors per material/finish — no image textures (wood grain) yet.
- Command interpretation latency is ~713s per utterance (one `claude -p` call). - Command interpretation latency is ~713s per utterance (one `claude -p` call).
## License ## License

View File

@ -32,6 +32,7 @@ gui = [
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"pytest-cov>=4.0", "pytest-cov>=4.0",
"PyYAML>=6.0", # scripts/gen_wood_tools.py emits CmdForge tool YAML
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@ -7,20 +7,31 @@ Run this after changing the woodshop CLI to refresh the tools:
python scripts/gen_wood_tools.py python scripts/gen_wood_tools.py
""" """
import os import os
import shutil
import stat import stat
import sys
from pathlib import Path from pathlib import Path
import yaml import yaml
CMDFORGE_PY = "/home/rob/.local/share/pipx/venvs/cmdforge/bin/python" # The cmdforge interpreter for the generated wrappers — overridable via env so
# this script isn't pinned to one machine's pipx layout.
CMDFORGE_PY = os.environ.get(
"CMDFORGE_PY",
str(Path.home() / ".local" / "share" / "pipx" / "venvs" / "cmdforge" / "bin" / "python"))
CMDFORGE_DIR = Path.home() / ".cmdforge" CMDFORGE_DIR = Path.home() / ".cmdforge"
BIN_DIR = Path.home() / ".local" / "bin" BIN_DIR = Path.home() / ".local" / "bin"
WS = 'ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")'
# Resolved at TOOL RUNTIME (not generation time) so the tools are portable:
# the installed `woodshop` entry point if on PATH, else `python -m woodshop`.
WS = ("_ws = shutil.which('woodshop')\n"
"ws = [_ws] if _ws else [sys.executable, '-m', 'woodshop']")
def code(body: str) -> str: def code(body: str) -> str:
"""Wrap a command-building body that sets `cmd`, then runs it.""" """Wrap a command-building body that sets `cmd`, then runs it. `ws` is the
return (f"import subprocess, os\n{WS}\n{body}\n" resolved woodshop command PREFIX (a list)."""
return (f"import subprocess, shutil, sys\n{WS}\n{body}\n"
"r = subprocess.run(cmd, capture_output=True, text=True)\n" "r = subprocess.run(cmd, capture_output=True, text=True)\n"
"out = (r.stdout + r.stderr).strip()\n") "out = (r.stdout + r.stderr).strip()\n")
@ -34,7 +45,7 @@ TOOLS = {
{"flag": "--width", "variable": "width", "default": "", "description": "Panel width (REQUIRED for plywood, ignored for lumber), e.g. '24 in'"}, {"flag": "--width", "variable": "width", "default": "", "description": "Panel width (REQUIRED for plywood, ignored for lumber), e.g. '24 in'"},
], ],
"code": code( "code": code(
'cmd = [ws, "place", stock, length]\n' 'cmd = ws + ["place", stock, length]\n'
'if width != "": cmd += ["--width", str(width)]' 'if width != "": cmd += ["--width", str(width)]'
), ),
}, },
@ -48,7 +59,7 @@ TOOLS = {
{"flag": "--anchor", "variable": "anchor", "default": "end_b", "description": "Measure offset from 'end' (far end) or 'start'"}, {"flag": "--anchor", "variable": "anchor", "default": "end_b", "description": "Measure offset from 'end' (far end) or 'start'"},
], ],
"code": code( "code": code(
'cmd = [ws, "join", part_b]\n' 'cmd = ws + ["join", part_b]\n'
'if to: cmd += ["--to", to]\n' 'if to: cmd += ["--to", to]\n'
'if angle: cmd += ["--angle", str(angle)]\n' 'if angle: cmd += ["--angle", str(angle)]\n'
'if offset: cmd += ["--offset", offset]\n' 'if offset: cmd += ["--offset", offset]\n'
@ -61,14 +72,14 @@ TOOLS = {
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."}, {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
{"flag": "--tilt", "variable": "tilt", "default": "90", "description": "Tilt degrees, 90 = straight up (default 90)"}, {"flag": "--tilt", "variable": "tilt", "default": "90", "description": "Tilt degrees, 90 = straight up (default 90)"},
], ],
"code": code('cmd = [ws, "stand"] + ([part] if part else []) + ["--tilt", str(tilt)]'), "code": code('cmd = ws + ["stand"] + ([part] if part else []) + ["--tilt", str(tilt)]'),
}, },
"wood-lay": { "wood-lay": {
"description": "Lay a board flat / horizontal. Use for 'lay it down', 'make it flat', 'horizontal'.", "description": "Lay a board flat / horizontal. Use for 'lay it down', 'make it flat', 'horizontal'.",
"arguments": [ "arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."}, {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
], ],
"code": code('cmd = [ws, "lay"] + ([part] if part else [])'), "code": code('cmd = ws + ["lay"] + ([part] if part else [])'),
}, },
"wood-rotate": { "wood-rotate": {
"description": "Rotate / re-orient a board. Use for 'rotate', 'turn', 'angle it'.", "description": "Rotate / re-orient a board. Use for 'rotate', 'turn', 'angle it'.",
@ -79,7 +90,7 @@ TOOLS = {
{"flag": "--roll", "variable": "roll", "default": "", "description": "Rotation about the board's own length, degrees"}, {"flag": "--roll", "variable": "roll", "default": "", "description": "Rotation about the board's own length, degrees"},
], ],
"code": code( "code": code(
'cmd = [ws, "rotate"] + ([part] if part else [])\n' 'cmd = ws + ["rotate"] + ([part] if part else [])\n'
'if yaw != "": cmd += ["--yaw", str(yaw)]\n' 'if yaw != "": cmd += ["--yaw", str(yaw)]\n'
'if tilt != "": cmd += ["--tilt", str(tilt)]\n' 'if tilt != "": cmd += ["--tilt", str(tilt)]\n'
'if roll != "": cmd += ["--roll", str(roll)]' 'if roll != "": cmd += ["--roll", str(roll)]'
@ -94,7 +105,7 @@ TOOLS = {
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset (up/down)"}, {"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset (up/down)"},
], ],
"code": code( "code": code(
'cmd = [ws, "move"] + ([part] if part else [])\n' 'cmd = ws + ["move"] + ([part] if part else [])\n'
'if dx != "": cmd += ["--dx", dx]\n' 'if dx != "": cmd += ["--dx", dx]\n'
'if dy != "": cmd += ["--dy", dy]\n' 'if dy != "": cmd += ["--dy", dy]\n'
'if dz != "": cmd += ["--dz", dz]' 'if dz != "": cmd += ["--dz", dz]'
@ -106,7 +117,7 @@ TOOLS = {
{"flag": "--length", "variable": "length", "description": "New length with units, e.g. '4 ft'"}, {"flag": "--length", "variable": "length", "description": "New length with units, e.g. '4 ft'"},
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"}, {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
], ],
"code": code('cmd = [ws, "trim", length] + (["--part", part] if part else [])'), "code": code('cmd = ws + ["trim", length] + (["--part", part] if part else [])'),
}, },
"wood-copy": { "wood-copy": {
"description": "Duplicate a board, offset by dx/dy/dz. Use for 'copy', 'duplicate', 'another one like that'.", "description": "Duplicate a board, offset by dx/dy/dz. Use for 'copy', 'duplicate', 'another one like that'.",
@ -117,7 +128,7 @@ TOOLS = {
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset"}, {"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset"},
], ],
"code": code( "code": code(
'cmd = [ws, "copy"] + ([part] if part else [])\n' 'cmd = ws + ["copy"] + ([part] if part else [])\n'
'if dx != "": cmd += ["--dx", dx]\n' 'if dx != "": cmd += ["--dx", dx]\n'
'if dy != "": cmd += ["--dy", dy]\n' 'if dy != "": cmd += ["--dy", dy]\n'
'if dz != "": cmd += ["--dz", dz]' 'if dz != "": cmd += ["--dz", dz]'
@ -129,43 +140,43 @@ TOOLS = {
{"flag": "--name", "variable": "name", "description": "The name, e.g. 'front-left leg'"}, {"flag": "--name", "variable": "name", "description": "The name, e.g. 'front-left leg'"},
{"flag": "--part", "variable": "part", "default": "", "description": "Board id (default: most recent)"}, {"flag": "--part", "variable": "part", "default": "", "description": "Board id (default: most recent)"},
], ],
"code": code('cmd = [ws, "rename", name] + (["--part", part] if part else [])'), "code": code('cmd = ws + ["rename", name] + (["--part", part] if part else [])'),
}, },
"wood-sand": { "wood-sand": {
"description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.", "description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.",
"arguments": [ "arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit to sand the most recent board ('it')."}, {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit to sand the most recent board ('it')."},
], ],
"code": code('cmd = [ws, "sand"] + ([part] if part else [])'), "code": code('cmd = ws + ["sand"] + ([part] if part else [])'),
}, },
"wood-delete": { "wood-delete": {
"description": "Remove a board. Use for 'delete', 'remove', 'get rid of', 'scrap'.", "description": "Remove a board. Use for 'delete', 'remove', 'get rid of', 'scrap'.",
"arguments": [ "arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"}, {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
], ],
"code": code('cmd = [ws, "delete"] + ([part] if part else [])'), "code": code('cmd = ws + ["delete"] + ([part] if part else [])'),
}, },
"wood-select": { "wood-select": {
"description": "Set the current selection — the board future commands like 'rotate that' or 'delete it' act on. Use for 'select', 'pick', 'grab the', 'use the'.", "description": "Set the current selection — the board future commands like 'rotate that' or 'delete it' act on. Use for 'select', 'pick', 'grab the', 'use the'.",
"arguments": [ "arguments": [
{"flag": "--part", "variable": "part", "description": "Board id or name to select, e.g. p3 or 'front-left leg'"}, {"flag": "--part", "variable": "part", "description": "Board id or name to select, e.g. p3 or 'front-left leg'"},
], ],
"code": code('cmd = [ws, "select", part]'), "code": code('cmd = ws + ["select", part]'),
}, },
"wood-undo": { "wood-undo": {
"description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.", "description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.",
"arguments": [], "arguments": [],
"code": code('cmd = [ws, "undo"]'), "code": code('cmd = ws + ["undo"]'),
}, },
"wood-redo": { "wood-redo": {
"description": "Redo the last undone operation. Use for 'redo', 'put it back', 'never mind that undo'.", "description": "Redo the last undone operation. Use for 'redo', 'put it back', 'never mind that undo'.",
"arguments": [], "arguments": [],
"code": code('cmd = [ws, "redo"]'), "code": code('cmd = ws + ["redo"]'),
}, },
"wood-clear": { "wood-clear": {
"description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.", "description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.",
"arguments": [], "arguments": [],
"code": code('cmd = [ws, "clear"]'), "code": code('cmd = ws + ["clear"]'),
}, },
"wood-feature": { "wood-feature": {
"description": "Add a joinery feature to a board: tenon (male tongue), mortise (pocket), hole, slot, dado, or rabbet. Use for 'add a tenon', 'cut a mortise', 'drill a hole', 'cut a slot'.", "description": "Add a joinery feature to a board: tenon (male tongue), mortise (pocket), hole, slot, dado, or rabbet. Use for 'add a tenon', 'cut a mortise', 'drill a hole', 'cut a slot'.",
@ -182,7 +193,7 @@ TOOLS = {
{"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"}, {"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"},
], ],
"code": code( "code": code(
'cmd = [ws, "feature", kind]\n' 'cmd = ws + ["feature", kind]\n'
'if part: cmd += ["--part", part]\n' 'if part: cmd += ["--part", part]\n'
'if face: cmd += ["--face", face]\n' 'if face: cmd += ["--face", face]\n'
'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n' 'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n'
@ -197,64 +208,66 @@ TOOLS = {
{"flag": "--anchor", "variable": "anchor", "description": "Feature id that stays put, e.g. f1"}, {"flag": "--anchor", "variable": "anchor", "description": "Feature id that stays put, e.g. f1"},
{"flag": "--moving", "variable": "moving", "description": "Feature id whose board moves to mate, e.g. f2"}, {"flag": "--moving", "variable": "moving", "description": "Feature id whose board moves to mate, e.g. f2"},
], ],
"code": code('cmd = [ws, "connect", anchor, moving]'), "code": code('cmd = ws + ["connect", anchor, moving]'),
}, },
"wood-explode": { "wood-explode": {
"description": "Back connected boards off along their joint axes for an exploded view. Use for 'explode', 'back off the connections', 'show it pre-assembled'.", "description": "Back connected boards off along their joint axes for an exploded view. Use for 'explode', 'back off the connections', 'show it pre-assembled'.",
"arguments": [ "arguments": [
{"flag": "--distance", "variable": "distance", "description": "How far to separate, e.g. '3 in'"}, {"flag": "--distance", "variable": "distance", "description": "How far to separate, e.g. '3 in'"},
], ],
"code": code('cmd = [ws, "explode", distance]'), "code": code('cmd = ws + ["explode", distance]'),
}, },
"wood-assemble": { "wood-assemble": {
"description": "Re-seat all connections (reverse an explode / re-fit the joints). Use for 'assemble', 'put it back together', 're-fit', 'close it up'.", "description": "Re-seat all connections (reverse an explode / re-fit the joints). Use for 'assemble', 'put it back together', 're-fit', 'close it up'.",
"arguments": [], "arguments": [],
"code": code('cmd = [ws, "assemble"]'), "code": code('cmd = ws + ["assemble"]'),
}, },
"wood-disconnect": { "wood-disconnect": {
"description": "Break a connection so the pieces become independent (they stay where they are). Use for 'disconnect', 'break the connection', 'separate them'.", "description": "Break a connection so the pieces become independent (they stay where they are). Use for 'disconnect', 'break the connection', 'separate them'.",
"arguments": [ "arguments": [
{"flag": "--connection", "variable": "connection", "description": "Connection id, e.g. c1"}, {"flag": "--connection", "variable": "connection", "description": "Connection id, e.g. c1"},
], ],
"code": code('cmd = [ws, "disconnect", connection]'), "code": code('cmd = ws + ["disconnect", connection]'),
}, },
"wood-feature-delete": { "wood-feature-delete": {
"description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.", "description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.",
"arguments": [ "arguments": [
{"flag": "--fid", "variable": "fid", "description": "Feature id, e.g. f1"}, {"flag": "--fid", "variable": "fid", "description": "Feature id, e.g. f1"},
], ],
"code": code('cmd = [ws, "feature-delete", fid]'), "code": code('cmd = ws + ["feature-delete", fid]'),
}, },
"wood-cutlist": { "wood-cutlist": {
"description": "Report the cut list / bill of materials: every board, board-feet, and how much lumber to buy. Use for 'cut list', 'what do I need to buy', 'bill of materials', 'how much wood'.", "description": "Report the cut list / bill of materials: every board, board-feet, and how much lumber to buy. Use for 'cut list', 'what do I need to buy', 'bill of materials', 'how much wood'.",
"arguments": [], "arguments": [],
"code": code('cmd = [ws, "cutlist"]'), "code": code('cmd = ws + ["cutlist"]'),
}, },
"wood-save": { "wood-save": {
"description": "Save the current design as a named project. Use for 'save this as', 'save the project', 'remember this design'.", "description": "Save the current design as a named project. Use for 'save this as', 'save the project', 'remember this design'.",
"arguments": [ "arguments": [
{"flag": "--name", "variable": "name", "description": "Project name, e.g. 'coffee table'"}, {"flag": "--name", "variable": "name", "description": "Project name, e.g. 'coffee table'"},
], ],
"code": code('cmd = [ws, "save", name]'), "code": code('cmd = ws + ["save", name]'),
}, },
"wood-open": { "wood-open": {
"description": "Open a previously saved project (replaces the current scene). Use for 'open', 'load the', 'go back to my'.", "description": "Open a previously saved project (replaces the current scene). Use for 'open', 'load the', 'go back to my'.",
"arguments": [ "arguments": [
{"flag": "--name", "variable": "name", "description": "Name of the project to open"}, {"flag": "--name", "variable": "name", "description": "Name of the project to open"},
], ],
"code": code('cmd = [ws, "open", name]'), "code": code('cmd = ws + ["open", name]'),
}, },
"wood-projects": { "wood-projects": {
"description": "List saved projects. Use for 'what projects do I have', 'list my designs'.", "description": "List saved projects. Use for 'what projects do I have', 'list my designs'.",
"arguments": [], "arguments": [],
"code": code('cmd = [ws, "projects"]'), "code": code('cmd = ws + ["projects"]'),
}, },
} }
WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n' WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n'
'exec "{py}" -m cmdforge.runner "{name}" "$@"\n') 'exec "{py}" -m cmdforge.runner "{name}" "$@"\n')
for name, spec in TOOLS.items():
def main() -> None:
for name, spec in TOOLS.items():
tool_dir = CMDFORGE_DIR / name tool_dir = CMDFORGE_DIR / name
tool_dir.mkdir(parents=True, exist_ok=True) tool_dir.mkdir(parents=True, exist_ok=True)
config = { config = {
@ -268,4 +281,8 @@ for name, spec in TOOLS.items():
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY)) wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
print(f"created {name}") print(f"created {name}")
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}") print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")
if __name__ == "__main__":
main()

View File

@ -95,7 +95,7 @@ def format_cutlist(scene: Scene) -> str:
lines.append(" Total: " + tot) lines.append(" Total: " + tot)
if any(cut_length(p) > p.length_in for p in scene.parts): if any(cut_length(p) > p.length_in for p in scene.parts):
lines.append(" (cut lengths include protruding tenons)") lines.append(" (cut lengths include protruding tenons)")
lines.append("SHOPPING (8' sticks / 4×8 sheets, +10% waste)") lines.append("SHOPPING (8' sticks / 4×8 sheets, kerf-aware nesting)")
for stock, qty in shopping(scene).items(): for stock, qty in shopping(scene).items():
unit = "sheet(s)" if stock.startswith("ply-") else "stick(s)" unit = "sheet(s)" if stock.startswith("ply-") else "stick(s)"
lines.append(f" {qty} × {stock} {unit}") lines.append(f" {qty} × {stock} {unit}")

View File

@ -17,8 +17,8 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
@ -39,9 +39,15 @@ def load_schemas() -> str:
return _run(["pa-load-tools", "--filter", TOOL_FILTER, "--format", "anthropic"]) return _run(["pa-load-tools", "--filter", TOOL_FILTER, "--format", "anthropic"])
def woodshop_cmd() -> list[str]:
"""Resolve the `woodshop` CLI portably: the installed entry point if it's on
PATH, else `python -m woodshop` with the current interpreter."""
exe = shutil.which("woodshop")
return [exe] if exe else [sys.executable, "-m", "woodshop"]
def scene_summary() -> str: def scene_summary() -> str:
ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop") return _run(woodshop_cmd() + ["status"]) or "empty"
return _run([ws, "status"]) or "empty"
SYSTEM = """You are WoodShop, a voice-driven woodworking assistant. Translate the \ SYSTEM = """You are WoodShop, a voice-driven woodworking assistant. Translate the \

View File

@ -135,3 +135,14 @@ def test_handle_appends_to_history(monkeypatch):
history = [] history = []
driver.handle("hello", schemas="[]", voice=False, verbose=False, history=history) driver.handle("hello", schemas="[]", voice=False, verbose=False, history=history)
assert history == [("hello", "hi there")] assert history == [("hello", "hi there")]
def test_woodshop_cmd_prefers_path(monkeypatch):
monkeypatch.setattr(driver.shutil, "which", lambda name: "/opt/bin/woodshop")
assert driver.woodshop_cmd() == ["/opt/bin/woodshop"]
def test_woodshop_cmd_falls_back_to_module(monkeypatch):
monkeypatch.setattr(driver.shutil, "which", lambda name: None)
cmd = driver.woodshop_cmd()
assert cmd[1:] == ["-m", "woodshop"] and cmd[0] # python -m woodshop

33
tests/test_gen_tools.py Normal file
View File

@ -0,0 +1,33 @@
"""Smoke tests for the wood-* tool generator (the fragile external layer).
Loads the generator without running it (writes are under main()) and checks
every generated tool body is valid, portable Python."""
import importlib.util
from pathlib import Path
import pytest
pytest.importorskip("yaml")
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "gen_wood_tools.py"
def _load():
spec = importlib.util.spec_from_file_location("gen_wood_tools", _SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # safe: file-writing is under main()
return mod
def test_every_tool_body_compiles_and_is_portable():
mod = _load()
assert mod.TOOLS and "wood-place" in mod.TOOLS
for name, spec in mod.TOOLS.items():
compile(spec["code"], name, "exec") # valid Python
assert "ws + [" in spec["code"] or "ws +[" in spec["code"] # uses resolved prefix
assert "PycharmProjects" not in spec["code"] # no hardcoded local path
def test_generator_resolves_woodshop_at_runtime():
mod = _load()
assert "shutil.which('woodshop')" in mod.WS
assert "PycharmProjects" not in mod.WS