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:
parent
01c4dee0bc
commit
970b88bc7b
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -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 ~7–13s per utterance (one `claude -p` call).
|
- Command interpretation latency is ~7–13s per utterance (one `claude -p` call).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,75 +208,81 @@ 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():
|
|
||||||
tool_dir = CMDFORGE_DIR / name
|
def main() -> None:
|
||||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
for name, spec in TOOLS.items():
|
||||||
config = {
|
tool_dir = CMDFORGE_DIR / name
|
||||||
"name": name, "description": spec["description"], "category": "Other",
|
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||||
"version": "0.2.0", "arguments": spec["arguments"],
|
config = {
|
||||||
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
|
"name": name, "description": spec["description"], "category": "Other",
|
||||||
"output": "{out}",
|
"version": "0.2.0", "arguments": spec["arguments"],
|
||||||
}
|
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
|
||||||
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
|
"output": "{out}",
|
||||||
wrapper = BIN_DIR / name
|
}
|
||||||
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
|
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
|
||||||
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
wrapper = BIN_DIR / name
|
||||||
print(f"created {name}")
|
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
|
||||||
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")
|
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
print(f"created {name}")
|
||||||
|
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue