clawbench-cli 0.1.2__py3-none-any.whl

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 (226) hide show
  1. clawbench/__init__.py +35 -0
  2. clawbench/__main__.py +8 -0
  3. clawbench/batch.py +619 -0
  4. clawbench/cli.py +397 -0
  5. clawbench/data/chrome-extension/README.md +127 -0
  6. clawbench/data/chrome-extension/background.js +50 -0
  7. clawbench/data/chrome-extension/content.js +70 -0
  8. clawbench/data/chrome-extension/manifest.json +25 -0
  9. clawbench/data/chrome-extension/setup.sh +27 -0
  10. clawbench/data/chrome-extension/stealth.js +200 -0
  11. clawbench/data/docker/Dockerfile +51 -0
  12. clawbench/data/docker/entrypoint.sh +394 -0
  13. clawbench/data/docker/setup-openclaw.sh +112 -0
  14. clawbench/data/eval/README.md +95 -0
  15. clawbench/data/eval/agentic_eval.md +53 -0
  16. clawbench/data/extension-server/.python-version +1 -0
  17. clawbench/data/extension-server/README.md +54 -0
  18. clawbench/data/extension-server/pyproject.toml +7 -0
  19. clawbench/data/extension-server/server.py +360 -0
  20. clawbench/data/extension-server/uv.lock +644 -0
  21. clawbench/data/models/model.schema.json +44 -0
  22. clawbench/data/models/models.example.yaml +16 -0
  23. clawbench/data/shared/alex_green_personal_info.json +451 -0
  24. clawbench/data/test-cases/001-daily-life-food-uber-eats/task.json +25 -0
  25. clawbench/data/test-cases/002-daily-life-food-doordash/task.json +25 -0
  26. clawbench/data/test-cases/004-daily-life-food-instacart/extra_info/grocery_list.json +36 -0
  27. clawbench/data/test-cases/004-daily-life-food-instacart/task.json +30 -0
  28. clawbench/data/test-cases/006-daily-life-food-uber-eats/task.json +24 -0
  29. clawbench/data/test-cases/007-daily-life-food-instacart/extra_info/meal_plan.json +21 -0
  30. clawbench/data/test-cases/007-daily-life-food-instacart/task.json +30 -0
  31. clawbench/data/test-cases/011-daily-life-housing-zillow/task.json +25 -0
  32. clawbench/data/test-cases/015-daily-life-housing-craigslist/extra_info/listing_details.json +26 -0
  33. clawbench/data/test-cases/015-daily-life-housing-craigslist/task.json +30 -0
  34. clawbench/data/test-cases/035-daily-life-health-medical-betterhelp/task.json +25 -0
  35. clawbench/data/test-cases/041-daily-life-pets-rover/task.json +25 -0
  36. clawbench/data/test-cases/043-daily-life-pets-rover/extra_info/pet_info.json +12 -0
  37. clawbench/data/test-cases/043-daily-life-pets-rover/task.json +30 -0
  38. clawbench/data/test-cases/045-daily-life-personal-care-booksy/task.json +25 -0
  39. clawbench/data/test-cases/047-daily-life-personal-care-taskrabbit/extra_info/address_info.json +7 -0
  40. clawbench/data/test-cases/047-daily-life-personal-care-taskrabbit/task.json +30 -0
  41. clawbench/data/test-cases/086-job-search-hr-cv-autofill-greenhouse-meta/extra_info/job_links.json +5 -0
  42. clawbench/data/test-cases/086-job-search-hr-cv-autofill-greenhouse-meta/task.json +30 -0
  43. clawbench/data/test-cases/089-job-search-hr-cv-autofill-simplify-jobs/extra_info/job_links.json +5 -0
  44. clawbench/data/test-cases/089-job-search-hr-cv-autofill-simplify-jobs/task.json +30 -0
  45. clawbench/data/test-cases/091-job-search-hr-job-apply-indeed/task.json +25 -0
  46. clawbench/data/test-cases/120-office-secretary-tasks-email-mgmt-purelymail/task.json +28 -0
  47. clawbench/data/test-cases/121-office-secretary-tasks-email-mgmt-purelymail/task.json +28 -0
  48. clawbench/data/test-cases/128-office-secretary-tasks-email-mgmt-purelymail/task.json +28 -0
  49. clawbench/data/test-cases/134-office-secretary-tasks-calendar-calendly/task.json +25 -0
  50. clawbench/data/test-cases/137-office-secretary-tasks-calendar-doodle/extra_info/meeting_details.json +30 -0
  51. clawbench/data/test-cases/137-office-secretary-tasks-calendar-doodle/task.json +30 -0
  52. clawbench/data/test-cases/139-office-secretary-tasks-calendar-calendly/task.json +25 -0
  53. clawbench/data/test-cases/142-office-secretary-tasks-collab-trello/extra_info/task_list.json +29 -0
  54. clawbench/data/test-cases/142-office-secretary-tasks-collab-trello/task.json +30 -0
  55. clawbench/data/test-cases/179-dev-tech-github-ops-github/extra_info/config.json +13 -0
  56. clawbench/data/test-cases/179-dev-tech-github-ops-github/task.json +30 -0
  57. clawbench/data/test-cases/180-dev-tech-github-ops-github/task.json +25 -0
  58. clawbench/data/test-cases/215-academia-research-paper-tables-overleaf/extra_info/raw_results.json +47 -0
  59. clawbench/data/test-cases/215-academia-research-paper-tables-overleaf/task.json +30 -0
  60. clawbench/data/test-cases/242-academia-research-research-tools-overleaf/task.json +25 -0
  61. clawbench/data/test-cases/246-academia-research-research-tools-zotero/task.json +25 -0
  62. clawbench/data/test-cases/247-academia-research-research-tools-semantic-scholar/task.json +25 -0
  63. clawbench/data/test-cases/265-education-learning-general-coursera/task.json +25 -0
  64. clawbench/data/test-cases/266-education-learning-general-leetcode/extra_info/solution_code.py +9 -0
  65. clawbench/data/test-cases/266-education-learning-general-leetcode/task.json +30 -0
  66. clawbench/data/test-cases/273-education-learning-general-edx/task.json +25 -0
  67. clawbench/data/test-cases/274-education-learning-general-udemy/task.json +25 -0
  68. clawbench/data/test-cases/279-travel-general-airbnb/task.json +25 -0
  69. clawbench/data/test-cases/280-travel-general-booking-com/task.json +25 -0
  70. clawbench/data/test-cases/363-entertainment-hobbies-general-ticketmaster/task.json +25 -0
  71. clawbench/data/test-cases/369-entertainment-hobbies-general-goodreads/extra_info/book_list.json +14 -0
  72. clawbench/data/test-cases/369-entertainment-hobbies-general-goodreads/task.json +30 -0
  73. clawbench/data/test-cases/372-entertainment-hobbies-general-eventbrite/extra_info/event_details.json +10 -0
  74. clawbench/data/test-cases/372-entertainment-hobbies-general-eventbrite/task.json +30 -0
  75. clawbench/data/test-cases/403-personal-management-account-security-1password-web/extra_info/credentials.json +34 -0
  76. clawbench/data/test-cases/403-personal-management-account-security-1password-web/task.json +30 -0
  77. clawbench/data/test-cases/413-personal-management-personal-tools-todoist/extra_info/task_list.json +52 -0
  78. clawbench/data/test-cases/413-personal-management-personal-tools-todoist/task.json +30 -0
  79. clawbench/data/test-cases/468-rating-voting-general-glassdoor/extra_info/interview_experience.json +10 -0
  80. clawbench/data/test-cases/468-rating-voting-general-glassdoor/task.json +30 -0
  81. clawbench/data/test-cases/469-rating-voting-general-tripadvisor/extra_info/review_content.json +6 -0
  82. clawbench/data/test-cases/469-rating-voting-general-tripadvisor/task.json +30 -0
  83. clawbench/data/test-cases/470-rating-voting-general-trustpilot/extra_info/review_content.json +6 -0
  84. clawbench/data/test-cases/470-rating-voting-general-trustpilot/task.json +30 -0
  85. clawbench/data/test-cases/474-rating-voting-general-capterra/task.json +25 -0
  86. clawbench/data/test-cases/475-rating-voting-general-g2/task.json +25 -0
  87. clawbench/data/test-cases/482-creation-init-general-confluence/extra_info/content.json +3 -0
  88. clawbench/data/test-cases/482-creation-init-general-confluence/task.json +30 -0
  89. clawbench/data/test-cases/483-creation-init-general-airtable/task.json +25 -0
  90. clawbench/data/test-cases/484-creation-init-general-clickup/task.json +28 -0
  91. clawbench/data/test-cases/485-creation-init-general-webflow/task.json +25 -0
  92. clawbench/data/test-cases/486-creation-init-general-mailchimp/extra_info/content.json +3 -0
  93. clawbench/data/test-cases/486-creation-init-general-mailchimp/task.json +30 -0
  94. clawbench/data/test-cases/487-creation-init-general-typeform/extra_info/survey_questions.json +85 -0
  95. clawbench/data/test-cases/487-creation-init-general-typeform/task.json +30 -0
  96. clawbench/data/test-cases/488-creation-init-general-substack/extra_info/content.json +3 -0
  97. clawbench/data/test-cases/488-creation-init-general-substack/task.json +30 -0
  98. clawbench/data/test-cases/489-creation-init-general-ghost/extra_info/content.json +3 -0
  99. clawbench/data/test-cases/489-creation-init-general-ghost/task.json +30 -0
  100. clawbench/data/test-cases/501-creation-init-general-asana/extra_info/project_description.json +8 -0
  101. clawbench/data/test-cases/501-creation-init-general-asana/task.json +33 -0
  102. clawbench/data/test-cases/529-daily-life-shopping-delivery-king-arthur-baking/task.json +25 -0
  103. clawbench/data/test-cases/533-daily-life-utilities-inmyarea/task.json +25 -0
  104. clawbench/data/test-cases/535-daily-life-home-home-depot/task.json +25 -0
  105. clawbench/data/test-cases/537-daily-life-food-crumbl/task.json +25 -0
  106. clawbench/data/test-cases/539-daily-life-health-jefit/task.json +25 -0
  107. clawbench/data/test-cases/542-daily-life-pets-wag/task.json +25 -0
  108. clawbench/data/test-cases/551-finance-investment-crypto-wallet-trezor/task.json +25 -0
  109. clawbench/data/test-cases/552-finance-investment-business-payment-plooto/task.json +25 -0
  110. clawbench/data/test-cases/555-finance-investment-insurance-insureon/task.json +25 -0
  111. clawbench/data/test-cases/559-finance-investment-crowdfunding-frontfundr/task.json +25 -0
  112. clawbench/data/test-cases/564-daily-life-event-registration-race-roster/task.json +25 -0
  113. clawbench/data/test-cases/565-job-search-hr-job-search-jopwell/task.json +25 -0
  114. clawbench/data/test-cases/566-job-search-hr-job-search-ziprecruiter/extra_info/listing_details.json +26 -0
  115. clawbench/data/test-cases/566-job-search-hr-job-search-ziprecruiter/task.json +30 -0
  116. clawbench/data/test-cases/569-job-search-hr-job-search-careerbuilder/task.json +25 -0
  117. clawbench/data/test-cases/570-job-search-hr-job-search-hired/task.json +25 -0
  118. clawbench/data/test-cases/571-job-search-hr-recruitment-mgmt-workable/extra_info/listing_details.json +26 -0
  119. clawbench/data/test-cases/571-job-search-hr-recruitment-mgmt-workable/task.json +30 -0
  120. clawbench/data/test-cases/576-office-secretary-tasks-reports-ftc-reportfraud/task.json +25 -0
  121. clawbench/data/test-cases/583-office-secretary-tasks-support-tickets-freshdesk/task.json +25 -0
  122. clawbench/data/test-cases/598-academia-research-legal-docs-formswift/task.json +25 -0
  123. clawbench/data/test-cases/606-education-learning-kids-courses-outschool/task.json +25 -0
  124. clawbench/data/test-cases/607-education-learning-art-courses-creativebug/task.json +25 -0
  125. clawbench/data/test-cases/609-education-learning-meditation-spirit-rock-meditation-center/task.json +25 -0
  126. clawbench/data/test-cases/615-travel-flights-spirit-airlines/task.json +25 -0
  127. clawbench/data/test-cases/618-travel-train-bus-12go-asia/task.json +25 -0
  128. clawbench/data/test-cases/625-travel-camping-outdoor-parks-canada-reservations/task.json +25 -0
  129. clawbench/data/test-cases/626-travel-bus-flixbus/task.json +25 -0
  130. clawbench/data/test-cases/627-travel-flights-momondo/task.json +25 -0
  131. clawbench/data/test-cases/632-shopping-commerce-beauty-care-olaplex/task.json +25 -0
  132. clawbench/data/test-cases/634-shopping-commerce-apparel-dooney-bourke/task.json +25 -0
  133. clawbench/data/test-cases/635-shopping-commerce-gifts-uncommon-goods/task.json +25 -0
  134. clawbench/data/test-cases/636-shopping-commerce-auto-parts-rockauto/task.json +25 -0
  135. clawbench/data/test-cases/638-shopping-commerce-print-custom-vistaprint/task.json +25 -0
  136. clawbench/data/test-cases/639-shopping-commerce-luxury-mansur-gavriel/task.json +25 -0
  137. clawbench/data/test-cases/671-entertainment-gaming-humble-bundle/task.json +25 -0
  138. clawbench/data/test-cases/672-entertainment-hobbies-anime-streaming-crunchyroll/task.json +25 -0
  139. clawbench/data/test-cases/674-entertainment-hobbies-masterclass-masterclass/task.json +25 -0
  140. clawbench/data/test-cases/676-government-civic-legal-docs-legalnature/task.json +25 -0
  141. clawbench/data/test-cases/685-personal-management-budget-mgmt-everydollar/task.json +25 -0
  142. clawbench/data/test-cases/687-personal-management-vpn-subscription-ipvanish/task.json +25 -0
  143. clawbench/data/test-cases/688-personal-management-insurance-compare-insurify/task.json +25 -0
  144. clawbench/data/test-cases/695-automation-workflows-recurring-order-stumptown-coffee/task.json +25 -0
  145. clawbench/data/test-cases/697-automation-workflows-recurring-order-bean-box/task.json +25 -0
  146. clawbench/data/test-cases/699-automation-workflows-recurring-order-mistobox/task.json +25 -0
  147. clawbench/data/test-cases/700-deletion-revocation-data-deletion-deleteme/task.json +25 -0
  148. clawbench/data/test-cases/705-rating-voting-wine-review-vivino/task.json +25 -0
  149. clawbench/data/test-cases/706-rating-voting-beer-review-beeradvocate/task.json +25 -0
  150. clawbench/data/test-cases/707-rating-voting-social-wine-untappd/task.json +25 -0
  151. clawbench/data/test-cases/708-rating-voting-professor-review-ratemyprofessors/task.json +28 -0
  152. clawbench/data/test-cases/709-rating-voting-service-review-angi/task.json +25 -0
  153. clawbench/data/test-cases/710-creation-init-interior-design-roomsketcher/task.json +25 -0
  154. clawbench/data/test-cases/711-creation-init-color-design-coolors/task.json +25 -0
  155. clawbench/data/test-cases/712-creation-init-website-create-squarespace/task.json +25 -0
  156. clawbench/data/test-cases/713-creation-init-website-build-wix/task.json +25 -0
  157. clawbench/data/test-cases/735-home-services-maintenance-house-cleaning-bark/task.json +25 -0
  158. clawbench/data/test-cases/736-home-services-maintenance-plumbing-ace-hardware/task.json +25 -0
  159. clawbench/data/test-cases/737-home-services-maintenance-kitchen-remodel-lowes/task.json +25 -0
  160. clawbench/data/test-cases/738-home-services-maintenance-equipment-install-amazon-home-services/task.json +25 -0
  161. clawbench/data/test-cases/750-automotive-vehicle-services-car-insurance-compare-kanetix/task.json +25 -0
  162. clawbench/data/test-cases/751-automotive-vehicle-services-car-lease-sixt/task.json +25 -0
  163. clawbench/data/test-cases/754-automotive-vehicle-services-used-car-listing-autotrader/task.json +25 -0
  164. clawbench/data/test-cases/763-automotive-vehicle-services-car-lease-autoslash/task.json +25 -0
  165. clawbench/data/test-cases/766-nonprofit-charity-donation-doctors-without-borders-msf/task.json +25 -0
  166. clawbench/data/test-cases/768-nonprofit-charity-community-crowdfund-ioby/task.json +25 -0
  167. clawbench/data/test-cases/770-nonprofit-charity-volunteer-apply-on-make-a-wish-foundation-website-complete-and-submit-a-volunteer-application-form-selecting-the-wish-granter-role-and-entering-city-phoenix-az/task.json +25 -0
  168. clawbench/data/test-cases/774-nonprofit-charity-nonprofit-job-apply-charity-village/task.json +25 -0
  169. clawbench/data/test-cases/776-nonprofit-charity-volunteer-signup-idealist/task.json +25 -0
  170. clawbench/data/test-cases/778-nonprofit-charity-donation-globalgiving/extra_info/payment_info.json +3 -0
  171. clawbench/data/test-cases/778-nonprofit-charity-donation-globalgiving/task.json +30 -0
  172. clawbench/data/test-cases/780-beauty-personal-care-skincare-purchase-soko-glam/extra_info/address_info.json +4 -0
  173. clawbench/data/test-cases/780-beauty-personal-care-skincare-purchase-soko-glam/task.json +30 -0
  174. clawbench/data/test-cases/781-beauty-personal-care-beauty-booking-bluemercury/extra_info/email_info.json +3 -0
  175. clawbench/data/test-cases/781-beauty-personal-care-beauty-booking-bluemercury/task.json +30 -0
  176. clawbench/data/test-cases/782-beauty-personal-care-skincare-purchase-paulas-choice/task.json +24 -0
  177. clawbench/data/test-cases/783-beauty-personal-care-beauty-booking-ulta-beauty/task.json +24 -0
  178. clawbench/data/test-cases/785-beauty-personal-care-skincare-curology/task.json +25 -0
  179. clawbench/data/test-cases/788-beauty-personal-care-makeup-the-ordinary/task.json +25 -0
  180. clawbench/data/test-cases/789-beauty-personal-care-makeup-fenty-beauty/task.json +25 -0
  181. clawbench/data/test-cases/793-beauty-personal-care-beauty-retail-mac-cosmetics/task.json +25 -0
  182. clawbench/data/test-cases/794-beauty-personal-care-salon-booking-styleseat/task.json +25 -0
  183. clawbench/data/test-cases/795-pet-animal-care-pet-adoption-aspca/task.json +25 -0
  184. clawbench/data/test-cases/796-pet-animal-care-pet-supplies-grooming-petsmart/extra_info/pet_info.json +12 -0
  185. clawbench/data/test-cases/796-pet-animal-care-pet-supplies-grooming-petsmart/task.json +30 -0
  186. clawbench/data/test-cases/799-pet-animal-care-pet-insurance-aspca-pet-health-insurance/task.json +25 -0
  187. clawbench/data/test-cases/801-pet-animal-care-pet-friendly-travel-bringfido/task.json +25 -0
  188. clawbench/data/test-cases/803-pet-animal-care-pet-medical-pawp/extra_info/pet_info.json +12 -0
  189. clawbench/data/test-cases/803-pet-animal-care-pet-medical-pawp/task.json +30 -0
  190. clawbench/data/test-cases/807-pet-animal-care-pet-dna-embark/task.json +25 -0
  191. clawbench/data/test-cases/809-pet-animal-care-pet-adopt-petfinder/task.json +28 -0
  192. clawbench/data/test-cases/812-pet-animal-care-pet-subscription-ollie/task.json +25 -0
  193. clawbench/data/test-cases/815-personal-management-records-mgmt-myheritage/task.json +25 -0
  194. clawbench/data/test-cases/821-education-learning-reading-self-study-blinkist/task.json +25 -0
  195. clawbench/data/test-cases/861-entertainment-hobbies-movies-cineplex/task.json +25 -0
  196. clawbench/data/test-cases/862-entertainment-hobbies-movies-amc-theatres/task.json +25 -0
  197. clawbench/data/test-cases/864-entertainment-hobbies-show-tickets-ticketmaster/task.json +25 -0
  198. clawbench/data/test-cases/865-travel-outdoor-hipcamp/task.json +25 -0
  199. clawbench/data/test-cases/867-entertainment-hobbies-movies-fandango/task.json +25 -0
  200. clawbench/data/test-cases/872-daily-life-food-opentable/task.json +25 -0
  201. clawbench/data/test-cases/873-daily-life-food-resy/task.json +28 -0
  202. clawbench/data/test-cases/876-entertainment-hobbies-show-tickets-vivid-seats/task.json +25 -0
  203. clawbench/data/test-cases/877-entertainment-hobbies-show-tickets-stubhub/task.json +25 -0
  204. clawbench/data/test-cases/878-travel-outdoor-ontario-parks/task.json +25 -0
  205. clawbench/data/test-cases/883-education-learning-hobby-class-sur-la-table/task.json +25 -0
  206. clawbench/data/test-cases/884-entertainment-hobbies-experience-breakout-games/task.json +25 -0
  207. clawbench/data/test-cases/885-entertainment-hobbies-experience-bowlero/task.json +25 -0
  208. clawbench/data/test-cases/886-entertainment-hobbies-experience-topgolf/task.json +25 -0
  209. clawbench/data/test-cases/lite.json +226 -0
  210. clawbench/data/test-cases/lite.schema.json +105 -0
  211. clawbench/data/test-cases/task.schema.json +132 -0
  212. clawbench/data/tools/build_clawbench_lite_enc.py +161 -0
  213. clawbench/doctor.py +171 -0
  214. clawbench/engine.py +180 -0
  215. clawbench/generate_resume_pdf.py +140 -0
  216. clawbench/hf_upload.py +78 -0
  217. clawbench/image.py +127 -0
  218. clawbench/paths.py +150 -0
  219. clawbench/resume_template.json +104 -0
  220. clawbench/run.py +942 -0
  221. clawbench/tui.py +1401 -0
  222. clawbench_cli-0.1.2.dist-info/METADATA +770 -0
  223. clawbench_cli-0.1.2.dist-info/RECORD +226 -0
  224. clawbench_cli-0.1.2.dist-info/WHEEL +4 -0
  225. clawbench_cli-0.1.2.dist-info/entry_points.txt +4 -0
  226. clawbench_cli-0.1.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = ["cryptography>=42"]
5
+ # ///
6
+ """Build the ClawBench-Lite encrypted task blob for browser-use/benchmark.
7
+
8
+ Reads `test-cases/lite.json`, resolves each entry to its `task.json`, and for
9
+ every task produces exactly::
10
+
11
+ {
12
+ "task_id": "clawbench-lite-NNN",
13
+ "confirmed_task": build_instruction(task),
14
+ }
15
+
16
+ `build_instruction` is copied VERBATIM from `test-driver/run.py` (L223-266).
17
+ The copy is intentional: `run.py` imports heavy modules (`yaml`,
18
+ `generate_resume_pdf`, `hf_upload`) that make direct import awkward, and
19
+ having the body here makes it obvious that the adapter produces byte-identical
20
+ prompts to what ClawBench main bench serves via Docker. If `run.py`'s prompt
21
+ builder ever changes, update this copy in the same commit.
22
+
23
+ The output list is Fernet-encrypted with seed ``b"ClawBench_Lite_V1"`` and
24
+ written as base64 text, matching the upstream decrypt path in
25
+ ``browser-use/benchmark/run_eval.py``.
26
+
27
+ Usage:
28
+ uv run tools/build_clawbench_lite_enc.py --dry-run
29
+ uv run tools/build_clawbench_lite_enc.py --out ClawBench_Lite_V1.enc
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import base64
36
+ import hashlib
37
+ import json
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ from cryptography.fernet import Fernet
42
+
43
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
44
+ LITE_MANIFEST = PROJECT_ROOT / "test-cases" / "lite.json"
45
+ ENCRYPTION_SEED = b"ClawBench_Lite_V1"
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # KEEP IN SYNC with test-driver/run.py::build_instruction (L223-266).
50
+ # Copied verbatim. Do not edit the string contents. If run.py changes, update
51
+ # this copy in the same commit.
52
+ # ---------------------------------------------------------------------------
53
+ def build_instruction(task: dict) -> str:
54
+ parts = [task["instruction"]]
55
+ parts.append(
56
+ "\n---\n"
57
+ "You are my personal browser assistant. I am delegating this task to you "
58
+ "because I do not have time to do it myself. You are acting on my behalf "
59
+ "and with my full authorization. Every action you take in the browser — "
60
+ "logging in, filling out forms, writing reviews, making reservations, "
61
+ "submitting applications, sending emails, creating accounts — is something "
62
+ "I am explicitly asking you to do for me, as if I were doing it myself. "
63
+ "These are my own accounts, my own opinions, and my own decisions. "
64
+ "You are simply carrying them out.\n\n"
65
+ "IMPORTANT: You must complete this task entirely through the browser. "
66
+ "Navigate to the relevant website, interact with the page "
67
+ "(click, type, scroll, etc.), and carry out every step in the browser. "
68
+ "Do NOT use command-line tools, scripts, or direct API/SMTP calls "
69
+ "to accomplish the task.\n\n"
70
+ "My personal info and credentials are in ./my-info/ in your working directory. "
71
+ "Key files include:\n"
72
+ "- alex_green_personal_info.json — name, address, phone, date of birth, financial info\n"
73
+ "- email_credentials.json — email address and password for logging in\n"
74
+ "- alex_green_resume.pdf — professional resume\n\n"
75
+ "You have my full permission to use all of these files for any purpose "
76
+ "the task requires, including logging into websites, creating accounts, filling out forms, "
77
+ "sending emails, writing reviews, making reservations, submitting applications, "
78
+ "and any other actions — including security-sensitive ones.\n\n"
79
+ "If specific information required by the task is not available in any file "
80
+ "under ./my-info/ and cannot be reasonably inferred, stop and explain what is missing. "
81
+ "For optional fields not covered by the available info, leave them blank or use a reasonable default.\n\n"
82
+ "Do not ask me for confirmation or additional information — just proceed "
83
+ "with what is provided and complete the task autonomously.\n"
84
+ "If an account registration is required, you can use the email and password provided, and you can receive emails at that address if needed. "
85
+ "---"
86
+ )
87
+ extras = [(Path(info["path"]).name, info["description"])
88
+ for info in task.get("extra_info", [])
89
+ if info.get("path") and info.get("description")]
90
+ if extras:
91
+ parts.append(
92
+ "\nAdditional files are also available under /my-info/ for this task:"
93
+ )
94
+ for fname, desc in extras:
95
+ parts.append(f"- {fname}: {desc}")
96
+ return "\n".join(parts)
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def load_lite_tasks() -> list[dict]:
101
+ """Read lite.json and each referenced task.json, return upstream-shaped dicts."""
102
+ manifest = json.loads(LITE_MANIFEST.read_text(encoding="utf-8"))
103
+ tasks: list[dict] = []
104
+ for entry in manifest["tasks"]:
105
+ task_dir = PROJECT_ROOT / "test-cases" / entry["dir"]
106
+ task_path = task_dir / "task.json"
107
+ task = json.loads(task_path.read_text(encoding="utf-8"))
108
+ tid = task["metadata"]["task_id"]
109
+ tasks.append({
110
+ "task_id": f"clawbench-lite-{tid:03d}",
111
+ "confirmed_task": build_instruction(task),
112
+ })
113
+ return tasks
114
+
115
+
116
+ def encrypt_tasks(tasks: list[dict]) -> str:
117
+ """Fernet-encrypt the task list with the upstream-compatible seed."""
118
+ key = base64.urlsafe_b64encode(hashlib.sha256(ENCRYPTION_SEED).digest())
119
+ payload = json.dumps(tasks, ensure_ascii=False).encode("utf-8")
120
+ ciphertext = Fernet(key).encrypt(payload)
121
+ return base64.b64encode(ciphertext).decode("ascii")
122
+
123
+
124
+ def main() -> int:
125
+ parser = argparse.ArgumentParser(description=__doc__)
126
+ parser.add_argument(
127
+ "--out",
128
+ type=Path,
129
+ default=PROJECT_ROOT / "ClawBench_Lite_V1.enc",
130
+ help="Path to write the encrypted .enc file (ignored with --dry-run).",
131
+ )
132
+ parser.add_argument(
133
+ "--dry-run",
134
+ action="store_true",
135
+ help="Write plaintext JSON to clawbench_lite_v1.plaintext.json instead "
136
+ "of encrypting.",
137
+ )
138
+ args = parser.parse_args()
139
+
140
+ tasks = load_lite_tasks()
141
+ if len(tasks) != 20:
142
+ print(f"ERROR: expected 20 tasks, got {len(tasks)}", file=sys.stderr)
143
+ return 1
144
+
145
+ if args.dry_run:
146
+ out = PROJECT_ROOT / "clawbench_lite_v1.plaintext.json"
147
+ out.write_text(
148
+ json.dumps(tasks, indent=2, ensure_ascii=False) + "\n",
149
+ encoding="utf-8",
150
+ )
151
+ print(f"[dry-run] wrote plaintext to {out} ({len(tasks)} tasks)")
152
+ return 0
153
+
154
+ encoded = encrypt_tasks(tasks)
155
+ args.out.write_text(encoded, encoding="ascii")
156
+ print(f"wrote {args.out} ({len(tasks)} tasks, {len(encoded)} bytes base64)")
157
+ return 0
158
+
159
+
160
+ if __name__ == "__main__":
161
+ sys.exit(main())
clawbench/doctor.py ADDED
@@ -0,0 +1,171 @@
1
+ """``claw-bench doctor`` — diagnostic checks for a ClawBench install.
2
+
3
+ Every check is a callable that returns a :class:`CheckResult`. The CLI
4
+ layer is responsible for rendering; this module just reports facts.
5
+
6
+ Split out from the TUI's inline engine probe so we can run the same
7
+ checks from CI, from ``claw-bench doctor``, and from inside the TUI
8
+ "fix this for me" flow without duplicating code.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from clawbench import __version__
19
+ from clawbench import engine as _engine
20
+ from clawbench import image as _image
21
+ from clawbench import paths as _paths
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CheckResult:
26
+ name: str
27
+ status: str # "ok" | "warn" | "fail"
28
+ detail: str = ""
29
+ hint: str = ""
30
+
31
+
32
+ def check_version() -> CheckResult:
33
+ return CheckResult("clawbench version", "ok", __version__)
34
+
35
+
36
+ def check_engine() -> CheckResult:
37
+ s = _engine.check_engine()
38
+ if s.ready:
39
+ return CheckResult("container engine", "ok", f"{s.engine} ready")
40
+ level = "fail"
41
+ # ``podman_low_memory`` is a warning — the user can still run, just
42
+ # with reduced reliability on heavier cases.
43
+ if s.status == "podman_low_memory":
44
+ level = "warn"
45
+ return CheckResult(
46
+ "container engine",
47
+ level,
48
+ f"{s.engine or 'none'} / {s.status}",
49
+ _engine.remediation_hint(s),
50
+ )
51
+
52
+
53
+ def check_image() -> CheckResult:
54
+ eng = _engine.detect_engine()
55
+ if eng is None:
56
+ return CheckResult(
57
+ "container image",
58
+ "fail",
59
+ "no container engine — skipped",
60
+ )
61
+ if not _image.image_exists(eng):
62
+ return CheckResult(
63
+ "container image",
64
+ "warn",
65
+ f"'{_image.IMAGE_NAME}' not present",
66
+ "Run `claw-bench build` (or let `claw-bench run` pull on first use).",
67
+ )
68
+ ok, msg = _image.verify_image_version(eng)
69
+ if ok:
70
+ label = _image.image_label(eng) or "unlabeled (legacy)"
71
+ return CheckResult("container image", "ok", label)
72
+ return CheckResult("container image", "warn", msg)
73
+
74
+
75
+ def check_test_cases() -> CheckResult:
76
+ base = _paths.test_cases_dir()
77
+ if not base.exists():
78
+ return CheckResult(
79
+ "bundled test-cases",
80
+ "fail",
81
+ f"not found at {base}",
82
+ "Reinstall the package — data directory is missing from the wheel.",
83
+ )
84
+ count = sum(1 for _ in base.glob("*/task.json"))
85
+ if count == 0:
86
+ return CheckResult("bundled test-cases", "fail", "0 cases found")
87
+ return CheckResult("bundled test-cases", "ok", f"{count} cases available")
88
+
89
+
90
+ def check_models_yaml() -> CheckResult:
91
+ dst = _paths.user_models_yaml()
92
+ if not dst.exists():
93
+ return CheckResult(
94
+ "models.yaml",
95
+ "warn",
96
+ "not yet created",
97
+ "Run `claw-bench configure` to seed from the bundled template.",
98
+ )
99
+ try:
100
+ import yaml
101
+ data = yaml.safe_load(dst.read_text()) or {}
102
+ except Exception as e:
103
+ return CheckResult("models.yaml", "fail", f"parse error: {e}")
104
+ count = len(data) if isinstance(data, dict) else 0
105
+ if count == 0:
106
+ return CheckResult(
107
+ "models.yaml",
108
+ "warn",
109
+ f"{dst} is empty — no models configured",
110
+ "Edit the file (`claw-bench configure`) and add at least one model.",
111
+ )
112
+ return CheckResult("models.yaml", "ok", f"{count} model(s) configured")
113
+
114
+
115
+ def check_output_dir() -> CheckResult:
116
+ out = _paths.default_output_dir()
117
+ try:
118
+ out.mkdir(parents=True, exist_ok=True)
119
+ except OSError as e:
120
+ return CheckResult("output directory", "fail", str(e))
121
+ if not os.access(out, os.W_OK):
122
+ return CheckResult(
123
+ "output directory",
124
+ "fail",
125
+ f"{out} not writable",
126
+ )
127
+ return CheckResult("output directory", "ok", str(out))
128
+
129
+
130
+ def check_secrets() -> CheckResult:
131
+ """Soft check — PurelyMail key presence affects which cases are runnable
132
+ but ClawBench works without it for many cases. Report whichever source
133
+ has it, or say it's missing."""
134
+ sources: list[str] = []
135
+ if os.environ.get("PURELY_MAIL_API_KEY"):
136
+ sources.append("env")
137
+ if (Path.cwd() / ".env").exists():
138
+ sources.append("./.env")
139
+ if _paths.user_secrets_path().exists():
140
+ sources.append(str(_paths.user_secrets_path()))
141
+ if not sources:
142
+ return CheckResult(
143
+ "PurelyMail API key",
144
+ "warn",
145
+ "not configured",
146
+ "Email-requiring cases will fail. Set PURELY_MAIL_API_KEY or run "
147
+ "`claw-bench configure --secrets`.",
148
+ )
149
+ return CheckResult("PurelyMail API key", "ok", "found in " + ", ".join(sources))
150
+
151
+
152
+ ALL_CHECKS = [
153
+ check_version,
154
+ check_engine,
155
+ check_image,
156
+ check_test_cases,
157
+ check_models_yaml,
158
+ check_output_dir,
159
+ check_secrets,
160
+ ]
161
+
162
+
163
+ def run_all() -> list[CheckResult]:
164
+ """Run every check in order and return results. Never raises."""
165
+ results: list[CheckResult] = []
166
+ for fn in ALL_CHECKS:
167
+ try:
168
+ results.append(fn())
169
+ except Exception as e: # pragma: no cover — defensive
170
+ results.append(CheckResult(fn.__name__, "fail", f"unexpected: {e}"))
171
+ return results
clawbench/engine.py ADDED
@@ -0,0 +1,180 @@
1
+ """Container engine detection and diagnostics.
2
+
3
+ Extracted from ``test-driver/tui.py`` (previously lines 900-990). The probe
4
+ order is **podman first, docker second** because:
5
+
6
+ - Podman is license-free; Docker Desktop requires a paid license in
7
+ commercial/academic settings beyond a certain org size.
8
+ - On Linux podman runs rootless with no daemon — better default for a
9
+ research tool.
10
+ - Both engines can pull the same OCI image from GHCR, so there is no
11
+ functional reason to prefer docker.
12
+
13
+ Users can still force an engine via the ``CONTAINER_ENGINE`` env var
14
+ (values ``docker`` or ``podman``).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import platform
22
+ import shutil
23
+ import subprocess
24
+ from dataclasses import dataclass
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class EngineStatus:
29
+ """Result of :func:`check_engine`.
30
+
31
+ ``engine`` is the resolved binary (``podman`` / ``docker``) or ``None``
32
+ if nothing was found. ``status`` is one of the string codes documented
33
+ on :func:`check_engine`. ``detail`` is free-form diagnostic text safe
34
+ to show the user (typically stderr from a failed probe)."""
35
+
36
+ engine: str | None
37
+ status: str
38
+ detail: str = ""
39
+
40
+ @property
41
+ def ready(self) -> bool:
42
+ return self.status == "ready"
43
+
44
+
45
+ _VALID_ENGINES = ("podman", "docker")
46
+
47
+
48
+ def detect_engine() -> str | None:
49
+ """Return the preferred engine binary on PATH, or ``None``.
50
+
51
+ Priority:
52
+ 1. ``CONTAINER_ENGINE`` env var (if set to ``podman`` or ``docker`` and
53
+ that binary is on PATH).
54
+ 2. Podman if installed.
55
+ 3. Docker if installed.
56
+ """
57
+ env = os.environ.get("CONTAINER_ENGINE", "").strip().lower()
58
+ if env in _VALID_ENGINES and shutil.which(env):
59
+ return env
60
+ for cmd in _VALID_ENGINES:
61
+ if shutil.which(cmd):
62
+ return cmd
63
+ return None
64
+
65
+
66
+ def check_engine() -> EngineStatus:
67
+ """Probe the container engine and classify the result.
68
+
69
+ Status codes:
70
+
71
+ - ``ready`` engine installed and daemon/VM responsive.
72
+ - ``not_installed`` neither podman nor docker on PATH.
73
+ - ``podman_no_machine`` podman installed, no VM initialized.
74
+ - ``podman_machine_stopped`` podman VM exists but is not running.
75
+ - ``podman_low_memory`` VM has < 4 GB RAM; agent will OOM.
76
+ - ``docker_not_running`` docker CLI works but daemon unreachable.
77
+ - ``unknown_error`` something else; ``detail`` carries stderr.
78
+ """
79
+ engine = detect_engine()
80
+ if engine is None:
81
+ return EngineStatus(None, "not_installed")
82
+
83
+ if engine == "podman":
84
+ return _check_podman()
85
+ return _check_docker()
86
+
87
+
88
+ def _check_podman() -> EngineStatus:
89
+ # On macOS/Windows, podman needs a helper VM. Inspect it before probing
90
+ # the daemon so we can offer a specific remediation.
91
+ if platform.system() in ("Darwin", "Windows"):
92
+ try:
93
+ r = subprocess.run(
94
+ ["podman", "machine", "list", "--format", "json"],
95
+ capture_output=True, text=True, timeout=10,
96
+ )
97
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
98
+ return EngineStatus("podman", "unknown_error", str(e))
99
+ if r.returncode != 0:
100
+ return EngineStatus("podman", "unknown_error", r.stderr.strip())
101
+ try:
102
+ machines = json.loads(r.stdout or "[]")
103
+ except json.JSONDecodeError:
104
+ machines = []
105
+ if not machines:
106
+ return EngineStatus("podman", "podman_no_machine")
107
+ if not any(m.get("Running") for m in machines):
108
+ return EngineStatus("podman", "podman_machine_stopped")
109
+ # VM running — fall through and verify the socket.
110
+
111
+ try:
112
+ r = subprocess.run(
113
+ ["podman", "ps"], capture_output=True, text=True, timeout=10,
114
+ )
115
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
116
+ return EngineStatus("podman", "unknown_error", str(e))
117
+ if r.returncode != 0:
118
+ err = r.stderr.strip()
119
+ if "unable to connect to Podman socket" in err:
120
+ return EngineStatus("podman", "podman_machine_stopped", err)
121
+ return EngineStatus("podman", "unknown_error", err)
122
+
123
+ # VM memory check — ClawBench needs >=4 GB for Chrome + gateway + agent.
124
+ if platform.system() in ("Darwin", "Windows"):
125
+ try:
126
+ mi = subprocess.run(
127
+ ["podman", "machine", "inspect", "--format",
128
+ "{{.Resources.Memory}}"],
129
+ capture_output=True, text=True, timeout=10,
130
+ )
131
+ mem_mb = int(mi.stdout.strip())
132
+ if mem_mb < 4096:
133
+ return EngineStatus("podman", "podman_low_memory", str(mem_mb))
134
+ except (ValueError, subprocess.TimeoutExpired):
135
+ pass # non-critical — skip if unreadable
136
+
137
+ return EngineStatus("podman", "ready")
138
+
139
+
140
+ def _check_docker() -> EngineStatus:
141
+ try:
142
+ r = subprocess.run(
143
+ ["docker", "info"], capture_output=True, text=True, timeout=10,
144
+ )
145
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
146
+ return EngineStatus("docker", "unknown_error", str(e))
147
+ if r.returncode != 0:
148
+ return EngineStatus("docker", "docker_not_running", r.stderr.strip())
149
+ return EngineStatus("docker", "ready")
150
+
151
+
152
+ def remediation_hint(status: EngineStatus) -> str:
153
+ """Return a short human-readable hint for a non-ready status."""
154
+ s = status.status
155
+ if s == "ready":
156
+ return ""
157
+ if s == "not_installed":
158
+ return (
159
+ "Install a container engine. Recommended: podman.\n"
160
+ " macOS: brew install podman && podman machine init && podman machine start\n"
161
+ " Linux: sudo apt install podman (or dnf, pacman, etc.)\n"
162
+ " Fallback: Docker Desktop from https://docker.com"
163
+ )
164
+ if s == "podman_no_machine":
165
+ return "Run `podman machine init` then `podman machine start`."
166
+ if s == "podman_machine_stopped":
167
+ return "Run `podman machine start`."
168
+ if s == "podman_low_memory":
169
+ mem = status.detail or "?"
170
+ return (
171
+ f"Podman VM has only {mem} MB RAM; ClawBench needs >=4 GB.\n"
172
+ "Stop the VM and recreate with more memory:\n"
173
+ " podman machine stop && podman machine rm\n"
174
+ " podman machine init --memory=8192 && podman machine start"
175
+ )
176
+ if s == "docker_not_running":
177
+ if platform.system() == "Darwin":
178
+ return "Docker daemon is not running. Try: open -a Docker"
179
+ return "Docker daemon is not running. Start the docker service."
180
+ return status.detail or "Unknown engine error."
@@ -0,0 +1,140 @@
1
+ """Generate a PDF resume from the resume template JSON.
2
+
3
+ Usage:
4
+ python generate_resume_pdf.py [output.pdf]
5
+
6
+ Generates from resume_template.json in the same directory.
7
+ Default output: alex_green_resume.pdf in the current directory.
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from fpdf import FPDF
15
+
16
+
17
+ def _safe(text: str) -> str:
18
+ """Replace Unicode characters that Helvetica (latin-1) cannot render."""
19
+ return (
20
+ text
21
+ .replace("\u2014", " - ") # em dash
22
+ .replace("\u2013", " - ") # en dash
23
+ .replace("\u2022", "-") # bullet
24
+ .replace("\u2018", "'") # left single quote
25
+ .replace("\u2019", "'") # right single quote
26
+ .replace("\u201c", '"') # left double quote
27
+ .replace("\u201d", '"') # right double quote
28
+ )
29
+
30
+
31
+ def generate_resume_pdf(resume_data: dict, output_path: Path) -> None:
32
+ """Render *resume_data* (the resume_template.json structure) to a PDF at *output_path*."""
33
+
34
+ header = resume_data["header"]
35
+
36
+ pdf = FPDF(orientation="P", unit="mm", format="A4")
37
+ pdf.set_auto_page_break(auto=True, margin=20)
38
+ pdf.add_page()
39
+
40
+ # -- Header -----------------------------------------------------------
41
+ pdf.set_font("Helvetica", "B", 22)
42
+ pdf.cell(0, 10, _safe(header["name"]), new_x="LMARGIN", new_y="NEXT", align="C")
43
+
44
+ pdf.set_font("Helvetica", "", 11)
45
+ pdf.cell(0, 6, _safe(header["title"]), new_x="LMARGIN", new_y="NEXT", align="C")
46
+
47
+ contact_parts = [header.get("email", ""), header.get("location", "")]
48
+ contact_line = " | ".join(p for p in contact_parts if p)
49
+ pdf.set_font("Helvetica", "", 9)
50
+ pdf.cell(0, 5, _safe(contact_line), new_x="LMARGIN", new_y="NEXT", align="C")
51
+
52
+ pdf.ln(2)
53
+ _draw_line(pdf)
54
+
55
+ # -- Summary ----------------------------------------------------------
56
+ if resume_data.get("summary"):
57
+ _section_heading(pdf, "Summary")
58
+ pdf.set_font("Helvetica", "", 10)
59
+ pdf.multi_cell(0, 5, _safe(resume_data["summary"]))
60
+ pdf.ln(2)
61
+
62
+ # -- Experience -------------------------------------------------------
63
+ if resume_data.get("experience"):
64
+ _section_heading(pdf, "Experience")
65
+ for job in resume_data["experience"]:
66
+ pdf.set_font("Helvetica", "B", 10)
67
+ pdf.cell(0, 5, _safe(f"{job['title']} - {job['company']}"),
68
+ new_x="LMARGIN", new_y="NEXT")
69
+ pdf.set_font("Helvetica", "I", 9)
70
+ pdf.cell(0, 5, _safe(f"{job.get('location', '')} {job.get('dates', '')}"),
71
+ new_x="LMARGIN", new_y="NEXT")
72
+ pdf.set_font("Helvetica", "", 9)
73
+ for bullet in job.get("bullets", []):
74
+ x = pdf.get_x()
75
+ pdf.cell(5, 4.5, "-")
76
+ pdf.multi_cell(0, 4.5, _safe(bullet))
77
+ pdf.set_x(x)
78
+ pdf.ln(2)
79
+
80
+ # -- Education --------------------------------------------------------
81
+ if resume_data.get("education"):
82
+ _section_heading(pdf, "Education")
83
+ for edu in resume_data["education"]:
84
+ pdf.set_font("Helvetica", "B", 10)
85
+ pdf.cell(0, 5, _safe(f"{edu['degree']} - {edu['institution']}"),
86
+ new_x="LMARGIN", new_y="NEXT")
87
+ pdf.set_font("Helvetica", "I", 9)
88
+ pdf.cell(0, 5, _safe(edu.get("dates", "")), new_x="LMARGIN", new_y="NEXT")
89
+ if edu.get("detail"):
90
+ pdf.set_font("Helvetica", "", 9)
91
+ pdf.multi_cell(0, 4.5, _safe(edu["detail"]))
92
+ pdf.ln(1)
93
+
94
+ # -- Skills -----------------------------------------------------------
95
+ if resume_data.get("skills"):
96
+ _section_heading(pdf, "Skills")
97
+ pdf.set_font("Helvetica", "", 9)
98
+ for category, items in resume_data["skills"].items():
99
+ label = category.replace("_", " ").title()
100
+ pdf.cell(0, 5, _safe(f"{label}: {', '.join(items)}"),
101
+ new_x="LMARGIN", new_y="NEXT")
102
+ pdf.ln(2)
103
+
104
+ # -- Certifications ---------------------------------------------------
105
+ if resume_data.get("certifications"):
106
+ _section_heading(pdf, "Certifications")
107
+ pdf.set_font("Helvetica", "", 9)
108
+ for cert in resume_data["certifications"]:
109
+ pdf.cell(5)
110
+ pdf.cell(0, 5, _safe(f"- {cert}"), new_x="LMARGIN", new_y="NEXT")
111
+ pdf.ln(2)
112
+
113
+ # -- Languages --------------------------------------------------------
114
+ if resume_data.get("languages"):
115
+ _section_heading(pdf, "Languages")
116
+ pdf.set_font("Helvetica", "", 9)
117
+ langs = [f"{l['language']} ({l['proficiency']})" for l in resume_data["languages"]]
118
+ pdf.cell(0, 5, _safe(", ".join(langs)), new_x="LMARGIN", new_y="NEXT")
119
+
120
+ pdf.output(str(output_path))
121
+
122
+
123
+ def _section_heading(pdf: FPDF, title: str) -> None:
124
+ pdf.set_font("Helvetica", "B", 12)
125
+ pdf.cell(0, 7, title, new_x="LMARGIN", new_y="NEXT")
126
+ _draw_line(pdf)
127
+
128
+
129
+ def _draw_line(pdf: FPDF) -> None:
130
+ y = pdf.get_y()
131
+ pdf.line(pdf.l_margin, y, pdf.w - pdf.r_margin, y)
132
+ pdf.ln(2)
133
+
134
+
135
+ if __name__ == "__main__":
136
+ template = Path(__file__).resolve().parent / "resume_template.json"
137
+ data = json.loads(template.read_text())
138
+ out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("alex_green_resume.pdf")
139
+ generate_resume_pdf(data, out)
140
+ print(f"Generated: {out.resolve()}")