"""Generate the wood-* CmdForge tools: the documented woodworking command vocabulary. Each is a thin wrapper over the `woodshop` CLI so the logic lives in one place; pa-load-tools turns these into Claude function schemas. The arg descriptions ARE the LLM's documentation — keep them clear and example-rich. Run this after changing the woodshop CLI to refresh the tools: python scripts/gen_wood_tools.py """ import os import shutil import stat import sys from pathlib import Path import yaml # 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" BIN_DIR = Path.home() / ".local" / "bin" # 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: """Wrap a command-building body that sets `cmd`, then runs it. `ws` is the 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" "out = (r.stdout + r.stderr).strip()\n") TOOLS = { "wood-place": { "description": "Place a new board of dimensional lumber, or a plywood panel. Use for 'place', 'add', 'put', 'grab', 'cut me a' board/panel.", "arguments": [ {"flag": "--stock", "variable": "stock", "description": "Lumber size e.g. 2x4, 2x6, 1x4, 4x4; or plywood e.g. ply-3/4, ply-1/2, ply-1/4"}, {"flag": "--length", "variable": "length", "description": "Length with units, e.g. '6 ft', '72 in', '3 ft 6 in'"}, {"flag": "--width", "variable": "width", "default": "", "description": "Panel width (REQUIRED for plywood, ignored for lumber), e.g. '24 in'"}, ], "code": code( 'cmd = ws + ["place", stock, length]\n' 'if width != "": cmd += ["--width", str(width)]' ), }, "wood-join": { "description": "Attach one board to another at an angle, optionally offset along the target. Use for 'attach', 'join', 'connect', 'fasten'.", "arguments": [ {"flag": "--part-b", "variable": "part_b", "description": "Id or name of the board being attached, e.g. p2"}, {"flag": "--to", "variable": "to", "default": "", "description": "Board to attach to, e.g. p1 or 'front rail'. Omit for the most recent board."}, {"flag": "--angle", "variable": "angle", "default": "90", "description": "Angle in degrees between the boards (default 90)"}, {"flag": "--offset", "variable": "offset", "default": "", "description": "Distance from the anchor end, e.g. '10 in'. Omit to attach at the end."}, {"flag": "--anchor", "variable": "anchor", "default": "end_b", "description": "Measure offset from 'end' (far end) or 'start'"}, ], "code": code( 'cmd = ws + ["join", part_b]\n' 'if to: cmd += ["--to", to]\n' 'if angle: cmd += ["--angle", str(angle)]\n' 'if offset: cmd += ["--offset", offset]\n' 'if anchor: cmd += ["--anchor", anchor]' ), }, "wood-stand": { "description": "Stand a board up vertically (e.g. a table or chair leg). Use for 'stand up', 'make it vertical', 'upright'.", "arguments": [ {"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)"}, ], "code": code('cmd = ws + ["stand"] + ([part] if part else []) + ["--tilt", str(tilt)]'), }, "wood-lay": { "description": "Lay a board flat / horizontal. Use for 'lay it down', 'make it flat', 'horizontal'.", "arguments": [ {"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 [])'), }, "wood-rotate": { "description": "Rotate / re-orient a board. Use for 'rotate', 'turn', 'angle it'.", "arguments": [ {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"}, {"flag": "--yaw", "variable": "yaw", "default": "", "description": "Heading in the horizontal plane, degrees"}, {"flag": "--tilt", "variable": "tilt", "default": "", "description": "Elevation toward vertical, degrees"}, {"flag": "--roll", "variable": "roll", "default": "", "description": "Rotation about the board's own length, degrees"}, ], "code": code( 'cmd = ws + ["rotate"] + ([part] if part else [])\n' 'if yaw != "": cmd += ["--yaw", str(yaw)]\n' 'if tilt != "": cmd += ["--tilt", str(tilt)]\n' 'if roll != "": cmd += ["--roll", str(roll)]' ), }, "wood-move": { "description": "Move/slide a board by an offset (or to an absolute position). Use for 'move', 'slide', 'shift', 'nudge'.", "arguments": [ {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"}, {"flag": "--dx", "variable": "dx", "default": "", "description": "X offset, e.g. '5 in', '-2 ft' (X = along the first board)"}, {"flag": "--dy", "variable": "dy", "default": "", "description": "Y offset"}, {"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset (up/down)"}, ], "code": code( 'cmd = ws + ["move"] + ([part] if part else [])\n' 'if dx != "": cmd += ["--dx", dx]\n' 'if dy != "": cmd += ["--dy", dy]\n' 'if dz != "": cmd += ["--dz", dz]' ), }, "wood-trim": { "description": "Cut a board down to a new length. Use for 'cut it to', 'trim to', 'shorten to', 'make it N feet'.", "arguments": [ {"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)"}, ], "code": code('cmd = ws + ["trim", length] + (["--part", part] if part else [])'), }, "wood-copy": { "description": "Duplicate a board, offset by dx/dy/dz. Use for 'copy', 'duplicate', 'another one like that'.", "arguments": [ {"flag": "--part", "variable": "part", "default": "", "description": "Board to copy (default: most recent)"}, {"flag": "--dx", "variable": "dx", "default": "", "description": "X offset for the copy, e.g. '46 in'"}, {"flag": "--dy", "variable": "dy", "default": "", "description": "Y offset"}, {"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset"}, ], "code": code( 'cmd = ws + ["copy"] + ([part] if part else [])\n' 'if dx != "": cmd += ["--dx", dx]\n' 'if dy != "": cmd += ["--dy", dy]\n' 'if dz != "": cmd += ["--dz", dz]' ), }, "wood-rename": { "description": "Give a board a human-friendly name so it can be referred to by name later. Use for 'call it', 'name it', 'this is the'.", "arguments": [ {"flag": "--name", "variable": "name", "description": "The name, e.g. 'front-left leg'"}, {"flag": "--part", "variable": "part", "default": "", "description": "Board id (default: most recent)"}, ], "code": code('cmd = ws + ["rename", name] + (["--part", part] if part else [])'), }, "wood-sand": { "description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.", "arguments": [ {"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 [])'), }, "wood-delete": { "description": "Remove a board. Use for 'delete', 'remove', 'get rid of', 'scrap'.", "arguments": [ {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"}, ], "code": code('cmd = ws + ["delete"] + ([part] if part else [])'), }, "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'.", "arguments": [ {"flag": "--part", "variable": "part", "description": "Board id or name to select, e.g. p3 or 'front-left leg'"}, ], "code": code('cmd = ws + ["select", part]'), }, "wood-undo": { "description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.", "arguments": [], "code": code('cmd = ws + ["undo"]'), }, "wood-redo": { "description": "Redo the last undone operation. Use for 'redo', 'put it back', 'never mind that undo'.", "arguments": [], "code": code('cmd = ws + ["redo"]'), }, "wood-clear": { "description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.", "arguments": [], "code": code('cmd = ws + ["clear"]'), }, "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'.", "arguments": [ {"flag": "--kind", "variable": "kind", "description": "tenon | mortise | hole | slot | dado | rabbet"}, {"flag": "--part", "variable": "part", "default": "", "description": "Board id/name (default: most recent)"}, {"flag": "--face", "variable": "face", "default": "end_b", "description": "Which face: end_a, end_b, top, bottom, left, right"}, {"flag": "--along", "variable": "along", "default": "", "description": "Position along the board (e.g. '3 in'), or 1st offset on an end"}, {"flag": "--across", "variable": "across", "default": "", "description": "Offset across the face from centre"}, {"flag": "--width", "variable": "width", "default": "", "description": "Feature width, e.g. '1.5 in'"}, {"flag": "--height", "variable": "height", "default": "", "description": "Feature height/thickness"}, {"flag": "--depth", "variable": "depth", "default": "", "description": "Cut depth, or tenon protrusion length"}, {"flag": "--diameter", "variable": "diameter", "default": "", "description": "Hole diameter, e.g. '0.5 in'"}, {"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"}, ], "code": code( 'cmd = ws + ["feature", kind]\n' 'if part: cmd += ["--part", part]\n' 'if face: cmd += ["--face", face]\n' 'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n' ' ("--height", height), ("--depth", depth), ("--diameter", diameter),\n' ' ("--rotation", rotation)]:\n' ' if val != "": cmd += [flag, str(val)]' ), }, "wood-connect": { "description": "Move/orient one board so its tenon/mortise seats into another's matching feature. Use for 'connect', 'assemble', 'join the pieces together', 'fit them together'.", "arguments": [ {"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"}, ], "code": code('cmd = ws + ["connect", anchor, moving]'), }, "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'.", "arguments": [ {"flag": "--distance", "variable": "distance", "description": "How far to separate, e.g. '3 in'"}, ], "code": code('cmd = ws + ["explode", distance]'), }, "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'.", "arguments": [], "code": code('cmd = ws + ["assemble"]'), }, "wood-disconnect": { "description": "Break a connection so the pieces become independent (they stay where they are). Use for 'disconnect', 'break the connection', 'separate them'.", "arguments": [ {"flag": "--connection", "variable": "connection", "description": "Connection id, e.g. c1"}, ], "code": code('cmd = ws + ["disconnect", connection]'), }, "wood-feature-delete": { "description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.", "arguments": [ {"flag": "--fid", "variable": "fid", "description": "Feature id, e.g. f1"}, ], "code": code('cmd = ws + ["feature-delete", fid]'), }, "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'.", "arguments": [], "code": code('cmd = ws + ["cutlist"]'), }, "wood-save": { "description": "Save the current design as a named project. Use for 'save this as', 'save the project', 'remember this design'.", "arguments": [ {"flag": "--name", "variable": "name", "description": "Project name, e.g. 'coffee table'"}, ], "code": code('cmd = ws + ["save", name]'), }, "wood-open": { "description": "Open a previously saved project (replaces the current scene). Use for 'open', 'load the', 'go back to my'.", "arguments": [ {"flag": "--name", "variable": "name", "description": "Name of the project to open"}, ], "code": code('cmd = ws + ["open", name]'), }, "wood-projects": { "description": "List saved projects. Use for 'what projects do I have', 'list my designs'.", "arguments": [], "code": code('cmd = ws + ["projects"]'), }, } WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n' 'exec "{py}" -m cmdforge.runner "{name}" "$@"\n') def main() -> None: for name, spec in TOOLS.items(): tool_dir = CMDFORGE_DIR / name tool_dir.mkdir(parents=True, exist_ok=True) config = { "name": name, "description": spec["description"], "category": "Other", "version": "0.2.0", "arguments": spec["arguments"], "steps": [{"type": "code", "code": spec["code"], "output_var": "out"}], "output": "{out}", } (tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False)) wrapper = BIN_DIR / name 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) print(f"created {name}") print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}") if __name__ == "__main__": main()