Add the puga command and plan pull; document the UI plan workflow

- pyproject.toml + puga/cli.py: `pip install -e .` installs a `puga <tool>` command, so no
  venv path is needed (replaces requirements.txt)
- tools/plan_push.py: `pull <uuid>` reads a plan (incl. UI edits) back into a YAML spec;
  recipe entries may carry an amount so switched-off recipes round trip; fixes a variable
  that overwrote the plan name in build_payload, with a regression test
- README: usage with the puga command, and a section on working on the same plans in the
  PRUNplanner UI and from Claude (write, edit in UI, read back)
- CLAUDE.md updated accordingly

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 00:37:20 +02:00
co-authored by Claude Sonnet 5
parent 455e04a520
commit 4c186ac677
13 changed files with 158 additions and 25 deletions
+27 -3
View File
@@ -4,6 +4,7 @@ DEFAULT IS DRY RUN. Guardrails (docs/decisions.md): only plans named '[PuGa] ...
--update refuses if the existing plan's name does not start with '[PuGa]'. Show the dry-run to the user and get a yes before --apply.
tools/plan_push.py list
tools/plan_push.py pull <uuid> [-o file.yaml] # read a plan (UI edits included) back into a YAML spec
tools/plan_push.py plans/examples/base_plus_hwp.yaml # dry run: validated payload summary
tools/plan_push.py plans/examples/base_plus_hwp.yaml --json # full JSON payload
tools/plan_push.py plans/examples/base_plus_hwp.yaml --apply # create (after user says yes)
@@ -66,7 +67,10 @@ def build_payload(spec: dict, recipes: list[dict], building_tickers: set[str]) -
tk = b["building"]
if tk not in building_tickers:
raise ValueError(f"unknown building {tk}")
recs = [{"recipeid": resolve_recipe(r, tk, recipes), "amount": 1} for r in b.get("recipes", [])]
recs = []
for r in b.get("recipes", []):
rname, amt = (r["recipe"], r.get("amount", 1)) if isinstance(r, dict) else (r, 1)
recs.append({"recipeid": resolve_recipe(rname, tk, recipes), "amount": int(amt)})
buildings.append({"name": tk, "amount": int(b["amount"]), "active_recipes": recs})
infra = [{"building": k, "amount": int(v)} for k, v in (spec.get("infrastructure") or {}).items()]
return {
@@ -80,8 +84,9 @@ def build_payload(spec: dict, recipes: list[dict], building_tickers: set[str]) -
def main():
ap = argparse.ArgumentParser()
ap.add_argument("spec", help="plans/*.yaml, 'list', or 'delete'")
ap.add_argument("target", nargs="?", help="uuid for 'delete'")
ap.add_argument("spec", help="plans/*.yaml, 'list', 'pull' or 'delete'")
ap.add_argument("target", nargs="?", help="uuid for 'delete' / 'pull'")
ap.add_argument("-o", "--out", help="with 'pull': write the YAML spec to this file")
ap.add_argument("--apply", action="store_true", help="actually write to PRUNplanner (creates a new plan)")
ap.add_argument("--update", metavar="UUID", help="with --apply: update this existing [PuGa] plan instead of creating")
ap.add_argument("--json", action="store_true")
@@ -91,6 +96,25 @@ def main():
for p in pp.request("GET", "/planning/plan/"):
print(p["uuid"], p.get("plan_name"), p.get("planet_natural_id"))
return
if a.spec == "pull": # read a plan from the account (e.g. after edits in the PRUNplanner UI) into a YAML spec
cur = pp.request("GET", f"/planning/plan/{a.target}/")
d = cur["plan_data"]
cg = cur.get("plan_cogc")
spec = {"name": cur["plan_name"], "planet": cur["planet_natural_id"], "permits": cur["plan_permits_used"],
"cogc": None if cg in (None, "---") else cg, "hq": cur["plan_corphq"],
"experts": {e["type"].upper(): e["amount"] for e in d["experts"] if e["amount"]},
"lux": {w["type"]: [w["lux1"], w["lux2"]] for w in d["workforce"] if not (w["lux1"] and w["lux2"])},
"infrastructure": {i["building"]: i["amount"] for i in d["infrastructure"]},
"buildings": [{"building": b["name"], "amount": b["amount"],
"recipes": [r["recipeid"] if r["amount"] == 1 else {"recipe": r["recipeid"], "amount": r["amount"]} for r in b["active_recipes"]]}
for b in d["buildings"]]}
text = yaml.safe_dump(spec, sort_keys=False, default_flow_style=None, width=140)
if a.out:
Path(a.out).write_text(text)
print("wrote", a.out)
else:
print(text)
return
if a.spec == "delete":
# delete is allowed only on request, and only for plans this tool made ([PuGa] prefix).
cur = pp.request("GET", f"/planning/plan/{a.target}/")