mobster-cli 0.4.3__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 (1009) hide show
  1. mobile_agent/__init__.py +4 -0
  2. mobile_agent/__main__.py +598 -0
  3. mobile_agent/accounts/__init__.py +1 -0
  4. mobile_agent/accounts/context.py +80 -0
  5. mobile_agent/accounts/mcp.py +55 -0
  6. mobile_agent/accounts/pacing.py +318 -0
  7. mobile_agent/accounts/register.py +78 -0
  8. mobile_agent/accounts/routes.py +212 -0
  9. mobile_agent/accounts/routing.py +69 -0
  10. mobile_agent/accounts/screen.py +273 -0
  11. mobile_agent/accounts/stats.py +54 -0
  12. mobile_agent/accounts/store.py +394 -0
  13. mobile_agent/agent_hooks.py +123 -0
  14. mobile_agent/ai_plans/__init__.py +7 -0
  15. mobile_agent/ai_plans/agents.py +96 -0
  16. mobile_agent/ai_plans/claude_code.py +208 -0
  17. mobile_agent/ai_plans/cli.py +400 -0
  18. mobile_agent/ai_plans/codex.py +258 -0
  19. mobile_agent/ai_plans/compat_chat.py +307 -0
  20. mobile_agent/ai_plans/device_login.py +164 -0
  21. mobile_agent/ai_plans/draft_client.py +68 -0
  22. mobile_agent/ai_plans/gemini.py +137 -0
  23. mobile_agent/ai_plans/instructions.py +65 -0
  24. mobile_agent/ai_plans/mcp_http.py +160 -0
  25. mobile_agent/ai_plans/phone_tools.py +892 -0
  26. mobile_agent/ai_plans/providers.py +235 -0
  27. mobile_agent/ai_plans/routes.py +160 -0
  28. mobile_agent/ai_plans/runner.py +400 -0
  29. mobile_agent/ai_plans/vault.py +114 -0
  30. mobile_agent/alerts.py +337 -0
  31. mobile_agent/android/__init__.py +18 -0
  32. mobile_agent/android/adb.py +268 -0
  33. mobile_agent/android/conditions.py +128 -0
  34. mobile_agent/android/doctor.py +54 -0
  35. mobile_agent/android/driver.py +137 -0
  36. mobile_agent/android/emulator.py +216 -0
  37. mobile_agent/android/logs.py +85 -0
  38. mobile_agent/android/manager.py +225 -0
  39. mobile_agent/android/records.py +31 -0
  40. mobile_agent/android/tree.py +96 -0
  41. mobile_agent/answer_shapes.py +230 -0
  42. mobile_agent/api_devices.py +333 -0
  43. mobile_agent/api_errors.py +17 -0
  44. mobile_agent/api_get.py +249 -0
  45. mobile_agent/api_http.py +467 -0
  46. mobile_agent/api_post.py +453 -0
  47. mobile_agent/api_routes.py +173 -0
  48. mobile_agent/app_api/__init__.py +4 -0
  49. mobile_agent/app_api/cli.py +151 -0
  50. mobile_agent/app_api/endpoints.py +193 -0
  51. mobile_agent/app_api/mcp.py +54 -0
  52. mobile_agent/app_api/openapi.py +146 -0
  53. mobile_agent/app_api/reading.py +172 -0
  54. mobile_agent/app_api/register.py +10 -0
  55. mobile_agent/app_api/screen.py +216 -0
  56. mobile_agent/app_api/service.py +382 -0
  57. mobile_agent/app_api/sessions.py +404 -0
  58. mobile_agent/app_api/tokens.py +167 -0
  59. mobile_agent/app_choice.py +131 -0
  60. mobile_agent/app_icons.py +183 -0
  61. mobile_agent/app_killer.py +69 -0
  62. mobile_agent/artifacts.py +606 -0
  63. mobile_agent/attach/__init__.py +13 -0
  64. mobile_agent/attach/cli.py +46 -0
  65. mobile_agent/attach/flutter.py +28 -0
  66. mobile_agent/attach/score.py +119 -0
  67. mobile_agent/attach/web.py +13 -0
  68. mobile_agent/attachments/__init__.py +1 -0
  69. mobile_agent/attachments/agent.py +418 -0
  70. mobile_agent/attachments/extract.py +217 -0
  71. mobile_agent/attachments/mcp.py +104 -0
  72. mobile_agent/attachments/register.py +73 -0
  73. mobile_agent/attachments/routes.py +326 -0
  74. mobile_agent/attachments/service.py +170 -0
  75. mobile_agent/attachments/sniff.py +152 -0
  76. mobile_agent/attachments/store.py +441 -0
  77. mobile_agent/audit_verify.py +436 -0
  78. mobile_agent/bench/README.md +410 -0
  79. mobile_agent/bench/__init__.py +0 -0
  80. mobile_agent/bench/__main__.py +394 -0
  81. mobile_agent/bench/ab.py +168 -0
  82. mobile_agent/bench/actuation.py +368 -0
  83. mobile_agent/bench/agents/__init__.py +9 -0
  84. mobile_agent/bench/agents/base.py +105 -0
  85. mobile_agent/bench/agents/plan_clients.py +245 -0
  86. mobile_agent/bench/agents/smart.py +533 -0
  87. mobile_agent/bench/checks.py +358 -0
  88. mobile_agent/bench/decide_gate.py +214 -0
  89. mobile_agent/bench/diagnose.py +181 -0
  90. mobile_agent/bench/evals/__init__.py +14 -0
  91. mobile_agent/bench/evals/build.py +565 -0
  92. mobile_agent/bench/evals/checkpoint.py +214 -0
  93. mobile_agent/bench/evals/irt.py +219 -0
  94. mobile_agent/bench/evals/l0.py +647 -0
  95. mobile_agent/bench/evals/l1.py +866 -0
  96. mobile_agent/bench/evals/models.py +124 -0
  97. mobile_agent/bench/evals/pairwise.py +189 -0
  98. mobile_agent/bench/evals/proxy.py +90 -0
  99. mobile_agent/bench/evals/sets.py +149 -0
  100. mobile_agent/bench/evals/traces.py +446 -0
  101. mobile_agent/bench/fixtures.py +176 -0
  102. mobile_agent/bench/guard_replay.py +129 -0
  103. mobile_agent/bench/h2-manifest.json +20 -0
  104. mobile_agent/bench/h3-manifest.json +20 -0
  105. mobile_agent/bench/heldout-manifest.json +32 -0
  106. mobile_agent/bench/heldout.py +375 -0
  107. mobile_agent/bench/iosworld.py +901 -0
  108. mobile_agent/bench/iosworld_stats.py +510 -0
  109. mobile_agent/bench/llm_units.py +116 -0
  110. mobile_agent/bench/manifest.json +93 -0
  111. mobile_agent/bench/metrics.py +66 -0
  112. mobile_agent/bench/phone.py +44 -0
  113. mobile_agent/bench/replay.py +172 -0
  114. mobile_agent/bench/report.py +207 -0
  115. mobile_agent/bench/runner.py +456 -0
  116. mobile_agent/bench/safety.py +310 -0
  117. mobile_agent/bench/stream_timing.py +213 -0
  118. mobile_agent/bench/suite.py +444 -0
  119. mobile_agent/bench/sweep.py +176 -0
  120. mobile_agent/bench/tape.py +798 -0
  121. mobile_agent/bench/tape_replay.py +88 -0
  122. mobile_agent/bench/trace_evals.py +128 -0
  123. mobile_agent/bench/trace_view.py +81 -0
  124. mobile_agent/bench/truth/iphone15pro.json +138 -0
  125. mobile_agent/bench/truth.py +340 -0
  126. mobile_agent/bench/tuning.py +543 -0
  127. mobile_agent/bench/tuning_analysis.py +656 -0
  128. mobile_agent/bench/vague-heldout-manifest.json +12 -0
  129. mobile_agent/bench/vague_rubric.py +135 -0
  130. mobile_agent/bench/vague_run.py +243 -0
  131. mobile_agent/brand.py +454 -0
  132. mobile_agent/brand_fonts.py +756 -0
  133. mobile_agent/build_identity.py +21 -0
  134. mobile_agent/cable_bt.py +623 -0
  135. mobile_agent/cable_bt_runtime.py +128 -0
  136. mobile_agent/catalog.py +69 -0
  137. mobile_agent/chart_svg.py +415 -0
  138. mobile_agent/chat_here.py +189 -0
  139. mobile_agent/cli_constants.py +5 -0
  140. mobile_agent/cli_parser.py +611 -0
  141. mobile_agent/cli_run.py +115 -0
  142. mobile_agent/compiled_read/__init__.py +21 -0
  143. mobile_agent/compiled_read/delta.py +89 -0
  144. mobile_agent/compiled_read/extractors.py +202 -0
  145. mobile_agent/compiled_read/routes.py +56 -0
  146. mobile_agent/completion.py +267 -0
  147. mobile_agent/compose.py +48 -0
  148. mobile_agent/config.py +114 -0
  149. mobile_agent/connectors/__init__.py +1 -0
  150. mobile_agent/connectors/api_session.py +76 -0
  151. mobile_agent/connectors/callback_page.py +146 -0
  152. mobile_agent/connectors/client.py +582 -0
  153. mobile_agent/connectors/github_app.py +55 -0
  154. mobile_agent/connectors/graph_api.py +396 -0
  155. mobile_agent/connectors/imports.py +170 -0
  156. mobile_agent/connectors/issue.py +66 -0
  157. mobile_agent/connectors/keystore.py +85 -0
  158. mobile_agent/connectors/marks.py +222 -0
  159. mobile_agent/connectors/oauth.py +476 -0
  160. mobile_agent/connectors/picker.py +228 -0
  161. mobile_agent/connectors/presets.py +591 -0
  162. mobile_agent/connectors/register.py +48 -0
  163. mobile_agent/connectors/routes.py +153 -0
  164. mobile_agent/connectors/service.py +966 -0
  165. mobile_agent/connectors/shared.py +136 -0
  166. mobile_agent/connectors/skill.py +446 -0
  167. mobile_agent/connectors/slack_api.py +269 -0
  168. mobile_agent/connectors/steps.py +92 -0
  169. mobile_agent/connectors/store.py +144 -0
  170. mobile_agent/connectors/vault.py +72 -0
  171. mobile_agent/console.py +178 -0
  172. mobile_agent/content_queue/__init__.py +1 -0
  173. mobile_agent/content_queue/client.py +263 -0
  174. mobile_agent/content_queue/importing.py +153 -0
  175. mobile_agent/content_queue/media_check.py +224 -0
  176. mobile_agent/content_queue/prompt.py +64 -0
  177. mobile_agent/content_queue/register.py +91 -0
  178. mobile_agent/content_queue/routes.py +370 -0
  179. mobile_agent/content_queue/runner.py +475 -0
  180. mobile_agent/content_queue/store.py +521 -0
  181. mobile_agent/contract.py +996 -0
  182. mobile_agent/contract_asks.py +248 -0
  183. mobile_agent/contract_prompt.py +45 -0
  184. mobile_agent/contract_screen.py +981 -0
  185. mobile_agent/contract_view.py +84 -0
  186. mobile_agent/corpus/__init__.py +26 -0
  187. mobile_agent/corpus/cli.py +63 -0
  188. mobile_agent/corpus/consent.py +55 -0
  189. mobile_agent/corpus/listener.py +76 -0
  190. mobile_agent/corpus/register.py +9 -0
  191. mobile_agent/corpus/skeleton.py +72 -0
  192. mobile_agent/corpus/store.py +143 -0
  193. mobile_agent/costs.py +161 -0
  194. mobile_agent/decider_scope.py +40 -0
  195. mobile_agent/decision_provider.py +189 -0
  196. mobile_agent/deeplinks.py +352 -0
  197. mobile_agent/device_info.py +116 -0
  198. mobile_agent/device_manager.py +978 -0
  199. mobile_agent/device_status.py +195 -0
  200. mobile_agent/device_targets.py +217 -0
  201. mobile_agent/devices.py +676 -0
  202. mobile_agent/devkit/__init__.py +64 -0
  203. mobile_agent/devkit/account_cli.py +90 -0
  204. mobile_agent/devkit/asc.py +562 -0
  205. mobile_agent/devkit/asc_cli.py +214 -0
  206. mobile_agent/devkit/ci.py +737 -0
  207. mobile_agent/devkit/ci_cli.py +196 -0
  208. mobile_agent/devkit/ci_hosts.py +194 -0
  209. mobile_agent/devkit/es256.py +197 -0
  210. mobile_agent/devkit/expo.py +440 -0
  211. mobile_agent/devkit/expo_cli.py +405 -0
  212. mobile_agent/devkit/expo_dev.py +203 -0
  213. mobile_agent/devkit/expo_routes.py +90 -0
  214. mobile_agent/devkit/fixtures_cli.py +133 -0
  215. mobile_agent/devkit/fixtures_routes.py +82 -0
  216. mobile_agent/devkit/links.py +375 -0
  217. mobile_agent/devkit/links_cli.py +123 -0
  218. mobile_agent/devkit/links_routes.py +83 -0
  219. mobile_agent/devkit/nightly.py +152 -0
  220. mobile_agent/devkit/shots.py +203 -0
  221. mobile_agent/devkit/testflight.py +155 -0
  222. mobile_agent/devkit/testflight_cli.py +127 -0
  223. mobile_agent/devkit/testflight_routes.py +111 -0
  224. mobile_agent/devkit/xcode.py +236 -0
  225. mobile_agent/devkit/xcode_cli.py +101 -0
  226. mobile_agent/devtools.py +419 -0
  227. mobile_agent/doctor.py +509 -0
  228. mobile_agent/doctor_fix.py +467 -0
  229. mobile_agent/downloads.py +55 -0
  230. mobile_agent/drivers.py +793 -0
  231. mobile_agent/dry_run.py +102 -0
  232. mobile_agent/effect_ledger.py +83 -0
  233. mobile_agent/engine_health.py +378 -0
  234. mobile_agent/engine_hooks.py +96 -0
  235. mobile_agent/engines.py +408 -0
  236. mobile_agent/env_file.py +63 -0
  237. mobile_agent/env_secrets.py +149 -0
  238. mobile_agent/errors.py +15 -0
  239. mobile_agent/evals/__init__.py +3 -0
  240. mobile_agent/evals/app_survey.py +149 -0
  241. mobile_agent/evals/development/__init__.py +1 -0
  242. mobile_agent/evals/development/score.py +276 -0
  243. mobile_agent/evals/latency_report.py +312 -0
  244. mobile_agent/evals/offline_report.py +374 -0
  245. mobile_agent/evals/oracles.py +298 -0
  246. mobile_agent/evals/redteam/__init__.py +15 -0
  247. mobile_agent/evals/redteam/__main__.py +72 -0
  248. mobile_agent/evals/redteam/cases.py +730 -0
  249. mobile_agent/evals/redteam/harness.py +604 -0
  250. mobile_agent/evals/redteam/live.py +139 -0
  251. mobile_agent/evals/redteam/plain.py +85 -0
  252. mobile_agent/evals/vision/__init__.py +0 -0
  253. mobile_agent/evals/vision/images/dog_001.jpg +0 -0
  254. mobile_agent/evals/vision/images/dog_002.jpg +0 -0
  255. mobile_agent/evals/vision/images/dog_003.jpg +0 -0
  256. mobile_agent/evals/vision/images/dog_004.jpg +0 -0
  257. mobile_agent/evals/vision/images/dog_005.jpg +0 -0
  258. mobile_agent/evals/vision/images/dog_006.jpg +0 -0
  259. mobile_agent/evals/vision/images/dog_007.jpg +0 -0
  260. mobile_agent/evals/vision/images/dog_010.jpg +0 -0
  261. mobile_agent/evals/vision/images/dog_011.jpg +0 -0
  262. mobile_agent/evals/vision/images/dog_013.jpg +0 -0
  263. mobile_agent/evals/vision/images/dog_014.jpg +0 -0
  264. mobile_agent/evals/vision/images/dog_015.jpg +0 -0
  265. mobile_agent/evals/vision/images/dog_016.jpg +0 -0
  266. mobile_agent/evals/vision/images/dog_017.jpg +0 -0
  267. mobile_agent/evals/vision/images/dog_019.jpg +0 -0
  268. mobile_agent/evals/vision/images/dog_020.jpg +0 -0
  269. mobile_agent/evals/vision/images/dog_021.jpg +0 -0
  270. mobile_agent/evals/vision/images/dog_022.jpg +0 -0
  271. mobile_agent/evals/vision/images/dog_023.jpg +0 -0
  272. mobile_agent/evals/vision/images/dog_024.jpg +0 -0
  273. mobile_agent/evals/vision/images/dog_026.jpg +0 -0
  274. mobile_agent/evals/vision/images/dog_027.jpg +0 -0
  275. mobile_agent/evals/vision/images/dog_034.jpg +0 -0
  276. mobile_agent/evals/vision/images/dog_035.jpg +0 -0
  277. mobile_agent/evals/vision/images/dog_038.jpg +0 -0
  278. mobile_agent/evals/vision/images/dog_042.jpg +0 -0
  279. mobile_agent/evals/vision/images/dog_047.jpg +0 -0
  280. mobile_agent/evals/vision/images/dog_052.jpg +0 -0
  281. mobile_agent/evals/vision/images/dog_058.jpg +0 -0
  282. mobile_agent/evals/vision/images/dog_062.jpg +0 -0
  283. mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
  284. mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
  285. mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
  286. mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
  287. mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
  288. mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
  289. mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
  290. mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
  291. mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
  292. mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
  293. mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
  294. mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
  295. mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
  296. mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
  297. mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
  298. mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
  299. mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
  300. mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
  301. mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
  302. mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
  303. mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
  304. mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
  305. mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
  306. mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
  307. mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
  308. mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
  309. mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
  310. mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
  311. mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
  312. mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
  313. mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
  314. mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
  315. mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
  316. mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
  317. mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
  318. mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
  319. mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
  320. mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
  321. mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
  322. mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
  323. mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
  324. mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
  325. mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
  326. mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
  327. mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
  328. mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
  329. mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
  330. mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
  331. mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
  332. mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
  333. mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
  334. mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
  335. mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
  336. mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
  337. mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
  338. mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
  339. mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
  340. mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
  341. mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
  342. mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
  343. mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
  344. mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
  345. mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
  346. mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
  347. mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
  348. mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
  349. mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
  350. mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
  351. mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
  352. mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
  353. mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
  354. mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
  355. mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
  356. mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
  357. mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
  358. mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
  359. mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
  360. mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
  361. mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
  362. mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
  363. mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
  364. mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
  365. mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
  366. mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
  367. mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
  368. mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
  369. mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
  370. mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
  371. mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
  372. mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
  373. mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
  374. mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
  375. mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
  376. mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
  377. mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
  378. mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
  379. mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
  380. mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
  381. mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
  382. mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
  383. mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
  384. mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
  385. mobile_agent/evals/vision/manifest.json +2355 -0
  386. mobile_agent/eventbus.py +110 -0
  387. mobile_agent/explain.py +34 -0
  388. mobile_agent/failure_line.py +197 -0
  389. mobile_agent/fanout.py +663 -0
  390. mobile_agent/feedback/__init__.py +2 -0
  391. mobile_agent/feedback/bundle.py +204 -0
  392. mobile_agent/feedback/outbox.py +196 -0
  393. mobile_agent/feedback/register.py +9 -0
  394. mobile_agent/feedback/routes.py +177 -0
  395. mobile_agent/fleet.py +841 -0
  396. mobile_agent/fleet_health.py +729 -0
  397. mobile_agent/fleet_phone.py +186 -0
  398. mobile_agent/frontier/__init__.py +79 -0
  399. mobile_agent/frontier/act.py +380 -0
  400. mobile_agent/frontier/actions.py +70 -0
  401. mobile_agent/frontier/agent.py +256 -0
  402. mobile_agent/frontier/approvals.py +366 -0
  403. mobile_agent/frontier/hooks.py +199 -0
  404. mobile_agent/frontier/interrupts.py +125 -0
  405. mobile_agent/frontier/observe.py +331 -0
  406. mobile_agent/frontier/phases.py +381 -0
  407. mobile_agent/frontier/pipeline.py +61 -0
  408. mobile_agent/frontier/prompt.py +384 -0
  409. mobile_agent/frontier/protocol.py +91 -0
  410. mobile_agent/frontier/safety.py +467 -0
  411. mobile_agent/frontier/saved.py +375 -0
  412. mobile_agent/frontier/screen.py +419 -0
  413. mobile_agent/frontier/skills.py +147 -0
  414. mobile_agent/frontier/stages/__init__.py +1 -0
  415. mobile_agent/frontier/stages/after.py +192 -0
  416. mobile_agent/frontier/stages/approve.py +138 -0
  417. mobile_agent/frontier/stages/controls.py +115 -0
  418. mobile_agent/frontier/stages/ends.py +102 -0
  419. mobile_agent/frontier/stages/gate.py +92 -0
  420. mobile_agent/frontier/stages/guards.py +132 -0
  421. mobile_agent/frontier/stages/points.py +155 -0
  422. mobile_agent/frontier/stages/send.py +177 -0
  423. mobile_agent/frontier/turn.py +151 -0
  424. mobile_agent/full_disk_access.py +83 -0
  425. mobile_agent/gemini.py +69 -0
  426. mobile_agent/genuine.py +120 -0
  427. mobile_agent/graph/__init__.py +4 -0
  428. mobile_agent/graph/catalog.py +789 -0
  429. mobile_agent/graph/chain.py +306 -0
  430. mobile_agent/graph/dispatch.py +98 -0
  431. mobile_agent/graph/draft.py +373 -0
  432. mobile_agent/graph/edit.py +76 -0
  433. mobile_agent/graph/estimate.py +189 -0
  434. mobile_agent/graph/expr.py +562 -0
  435. mobile_agent/graph/functions.py +271 -0
  436. mobile_agent/graph/infer.py +144 -0
  437. mobile_agent/graph/nodes.py +936 -0
  438. mobile_agent/graph/phone_steps.py +178 -0
  439. mobile_agent/graph/rerun.py +327 -0
  440. mobile_agent/graph/run.py +899 -0
  441. mobile_agent/graph/schema.py +842 -0
  442. mobile_agent/graph/state.py +161 -0
  443. mobile_agent/graph/values.py +198 -0
  444. mobile_agent/harness/__init__.py +2 -0
  445. mobile_agent/harness/checkpoints.py +468 -0
  446. mobile_agent/harness/control.py +101 -0
  447. mobile_agent/harness/prewarm.py +213 -0
  448. mobile_agent/harness/register.py +43 -0
  449. mobile_agent/harness/routes.py +114 -0
  450. mobile_agent/harness/settings.py +66 -0
  451. mobile_agent/harness/tools/__init__.py +67 -0
  452. mobile_agent/harness/tools/ask_user.py +102 -0
  453. mobile_agent/harness/tools/watch.py +120 -0
  454. mobile_agent/harness_api.py +815 -0
  455. mobile_agent/hedge.py +203 -0
  456. mobile_agent/help_topics.py +305 -0
  457. mobile_agent/hints.py +248 -0
  458. mobile_agent/history.py +69 -0
  459. mobile_agent/home.py +214 -0
  460. mobile_agent/http_pool.py +175 -0
  461. mobile_agent/image_policy.py +139 -0
  462. mobile_agent/inbox.py +148 -0
  463. mobile_agent/installed_apps.py +344 -0
  464. mobile_agent/integrations/__init__.py +1 -0
  465. mobile_agent/integrations/cli.py +472 -0
  466. mobile_agent/integrations/client_files.py +65 -0
  467. mobile_agent/integrations/clients.py +998 -0
  468. mobile_agent/integrations/doctor.py +185 -0
  469. mobile_agent/integrations/jsonc.py +383 -0
  470. mobile_agent/integrations/routes.py +135 -0
  471. mobile_agent/integrations/rules.py +112 -0
  472. mobile_agent/integrations/server_options.py +46 -0
  473. mobile_agent/integrations/skill.py +152 -0
  474. mobile_agent/integrations/skill_files.py +10 -0
  475. mobile_agent/integrations/textedit.py +309 -0
  476. mobile_agent/intents/__init__.py +17 -0
  477. mobile_agent/intents/atlas.py +216 -0
  478. mobile_agent/intents/callback.py +44 -0
  479. mobile_agent/intents/cli.py +55 -0
  480. mobile_agent/intents/harvest.py +91 -0
  481. mobile_agent/intents/plan.py +93 -0
  482. mobile_agent/ios/__init__.py +1 -0
  483. mobile_agent/ios/guide.py +16 -0
  484. mobile_agent/ios/kb.py +212 -0
  485. mobile_agent/ios/kb_data.py +1588 -0
  486. mobile_agent/ios/mcp.py +44 -0
  487. mobile_agent/ios/register.py +8 -0
  488. mobile_agent/ios/system.py +369 -0
  489. mobile_agent/journal.py +531 -0
  490. mobile_agent/keychain_acl.py +374 -0
  491. mobile_agent/keys.py +395 -0
  492. mobile_agent/latency_overlap.py +36 -0
  493. mobile_agent/latency_trace.py +170 -0
  494. mobile_agent/learned/__init__.py +15 -0
  495. mobile_agent/learned/app_path.py +160 -0
  496. mobile_agent/learned/code_read.py +38 -0
  497. mobile_agent/learned/parts.py +546 -0
  498. mobile_agent/learned/programs.py +691 -0
  499. mobile_agent/learned/register.py +171 -0
  500. mobile_agent/learned/routes.py +61 -0
  501. mobile_agent/learned/taught.py +329 -0
  502. mobile_agent/learned/teach.py +538 -0
  503. mobile_agent/learned/workflows.py +647 -0
  504. mobile_agent/learned/zero.py +41 -0
  505. mobile_agent/links.py +37 -0
  506. mobile_agent/listen.py +607 -0
  507. mobile_agent/lock_spike.py +278 -0
  508. mobile_agent/lockscreen.py +526 -0
  509. mobile_agent/login.py +354 -0
  510. mobile_agent/longform/__init__.py +15 -0
  511. mobile_agent/longform/ads.py +191 -0
  512. mobile_agent/longform/blocks.py +117 -0
  513. mobile_agent/longform/budget.py +265 -0
  514. mobile_agent/longform/context.py +285 -0
  515. mobile_agent/longform/costs.py +93 -0
  516. mobile_agent/longform/digest.py +145 -0
  517. mobile_agent/longform/extract.py +508 -0
  518. mobile_agent/longform/feed.py +505 -0
  519. mobile_agent/longform/followup.py +168 -0
  520. mobile_agent/longform/frames.py +82 -0
  521. mobile_agent/longform/identity.py +87 -0
  522. mobile_agent/longform/inbox.py +221 -0
  523. mobile_agent/longform/items.py +425 -0
  524. mobile_agent/longform/models.py +115 -0
  525. mobile_agent/longform/notebook.py +329 -0
  526. mobile_agent/longform/plan.py +614 -0
  527. mobile_agent/longform/recall.py +195 -0
  528. mobile_agent/longform/receipts.py +81 -0
  529. mobile_agent/longform/register.py +124 -0
  530. mobile_agent/longform/report.py +343 -0
  531. mobile_agent/longform/routes.py +102 -0
  532. mobile_agent/longform/session.py +644 -0
  533. mobile_agent/longform/store.py +146 -0
  534. mobile_agent/longform/synthesis.py +347 -0
  535. mobile_agent/mac_tools.py +223 -0
  536. mobile_agent/manual_control.py +143 -0
  537. mobile_agent/maps.py +149 -0
  538. mobile_agent/marks.py +92 -0
  539. mobile_agent/mcp_server/__init__.py +3 -0
  540. mobile_agent/mcp_server/__main__.py +29 -0
  541. mobile_agent/mcp_server/action_journal.py +24 -0
  542. mobile_agent/mcp_server/action_tools.py +638 -0
  543. mobile_agent/mcp_server/approvals.py +219 -0
  544. mobile_agent/mcp_server/catalog.py +92 -0
  545. mobile_agent/mcp_server/cli.py +194 -0
  546. mobile_agent/mcp_server/codes.py +147 -0
  547. mobile_agent/mcp_server/device_tools.py +306 -0
  548. mobile_agent/mcp_server/direct.py +272 -0
  549. mobile_agent/mcp_server/expo_tools.py +105 -0
  550. mobile_agent/mcp_server/phone_tools.py +510 -0
  551. mobile_agent/mcp_server/protocol.py +364 -0
  552. mobile_agent/mcp_server/registry.py +55 -0
  553. mobile_agent/mcp_server/run_tools.py +531 -0
  554. mobile_agent/mcp_server/schemas.py +425 -0
  555. mobile_agent/mcp_server/screen_tools.py +168 -0
  556. mobile_agent/mcp_server/session.py +469 -0
  557. mobile_agent/mcp_server/support.py +145 -0
  558. mobile_agent/mcp_server/tools.py +493 -0
  559. mobile_agent/memory/__init__.py +9 -0
  560. mobile_agent/memory/cli.py +207 -0
  561. mobile_agent/memory/extract.py +191 -0
  562. mobile_agent/memory/listener.py +100 -0
  563. mobile_agent/memory/model.py +144 -0
  564. mobile_agent/memory/project.py +96 -0
  565. mobile_agent/memory/provider.py +127 -0
  566. mobile_agent/memory/register.py +21 -0
  567. mobile_agent/memory/retrieval.py +172 -0
  568. mobile_agent/memory/routes.py +216 -0
  569. mobile_agent/memory/runs.py +124 -0
  570. mobile_agent/memory/store.py +806 -0
  571. mobile_agent/memory/writer.py +278 -0
  572. mobile_agent/mirroring.py +585 -0
  573. mobile_agent/mirroring_runtime.py +221 -0
  574. mobile_agent/model_clients.py +604 -0
  575. mobile_agent/narrate.py +813 -0
  576. mobile_agent/native_helpers.py +84 -0
  577. mobile_agent/netxray/__init__.py +1 -0
  578. mobile_agent/netxray/analyze.py +322 -0
  579. mobile_agent/netxray/attribution.py +62 -0
  580. mobile_agent/netxray/capture.py +88 -0
  581. mobile_agent/netxray/cli.py +142 -0
  582. mobile_agent/netxray/job.py +219 -0
  583. mobile_agent/netxray/mobster-pcap.c +560 -0
  584. mobile_agent/netxray/packets.py +410 -0
  585. mobile_agent/netxray/pcapng.py +223 -0
  586. mobile_agent/netxray/register.py +93 -0
  587. mobile_agent/netxray/report.py +214 -0
  588. mobile_agent/netxray/store.py +48 -0
  589. mobile_agent/netxray/summary.py +157 -0
  590. mobile_agent/netxray/target.py +67 -0
  591. mobile_agent/netxray/tool.py +66 -0
  592. mobile_agent/netxray/trackers.py +194 -0
  593. mobile_agent/notify_triggers/__init__.py +1 -0
  594. mobile_agent/notify_triggers/rules.py +39 -0
  595. mobile_agent/notify_triggers/sources.py +315 -0
  596. mobile_agent/notify_triggers/watch.py +135 -0
  597. mobile_agent/offers.py +86 -0
  598. mobile_agent/orchestrate.py +215 -0
  599. mobile_agent/outlets/__init__.py +1 -0
  600. mobile_agent/outlets/connect.py +249 -0
  601. mobile_agent/outlets/folder.py +180 -0
  602. mobile_agent/outlets/hooks.py +453 -0
  603. mobile_agent/outlets/inbound.py +472 -0
  604. mobile_agent/outlets/inbound_reply.py +57 -0
  605. mobile_agent/outlets/inbound_routes.py +81 -0
  606. mobile_agent/outlets/kinds.py +308 -0
  607. mobile_agent/outlets/mail.py +110 -0
  608. mobile_agent/outlets/net.py +86 -0
  609. mobile_agent/outlets/notion.py +244 -0
  610. mobile_agent/outlets/register.py +56 -0
  611. mobile_agent/outlets/routes.py +135 -0
  612. mobile_agent/outlets/service.py +580 -0
  613. mobile_agent/outlets/sheets.py +169 -0
  614. mobile_agent/outlets/slack.py +177 -0
  615. mobile_agent/outlets/store.py +194 -0
  616. mobile_agent/outlets/vault.py +80 -0
  617. mobile_agent/outlets/webhook.py +316 -0
  618. mobile_agent/output_contract.py +141 -0
  619. mobile_agent/owner_presence.py +211 -0
  620. mobile_agent/ownership.py +219 -0
  621. mobile_agent/packs/__init__.py +1 -0
  622. mobile_agent/packs/calendar.py +243 -0
  623. mobile_agent/packs/context.py +68 -0
  624. mobile_agent/packs/core.py +757 -0
  625. mobile_agent/packs/front_door.py +316 -0
  626. mobile_agent/packs/heal.py +214 -0
  627. mobile_agent/packs/health.py +122 -0
  628. mobile_agent/packs/insights.py +76 -0
  629. mobile_agent/packs/instagram.py +640 -0
  630. mobile_agent/packs/linkedin.py +106 -0
  631. mobile_agent/packs/maps.py +196 -0
  632. mobile_agent/packs/mcp.py +149 -0
  633. mobile_agent/packs/messages.py +270 -0
  634. mobile_agent/packs/notes.py +226 -0
  635. mobile_agent/packs/photos.py +166 -0
  636. mobile_agent/packs/pins.py +47 -0
  637. mobile_agent/packs/reddit.py +110 -0
  638. mobile_agent/packs/register.py +276 -0
  639. mobile_agent/packs/rows.py +108 -0
  640. mobile_agent/packs/safari.py +143 -0
  641. mobile_agent/packs/settings.py +253 -0
  642. mobile_agent/packs/social.py +300 -0
  643. mobile_agent/packs/threads.py +97 -0
  644. mobile_agent/packs/tiktok.py +455 -0
  645. mobile_agent/packs/tools.py +290 -0
  646. mobile_agent/packs/x.py +127 -0
  647. mobile_agent/packs/youtube.py +199 -0
  648. mobile_agent/parity.py +270 -0
  649. mobile_agent/paths.py +99 -0
  650. mobile_agent/perception.py +470 -0
  651. mobile_agent/permissions/__init__.py +32 -0
  652. mobile_agent/permissions/consequence.py +535 -0
  653. mobile_agent/permissions/exceptions.py +99 -0
  654. mobile_agent/permissions/gate.py +316 -0
  655. mobile_agent/permissions/outside.py +316 -0
  656. mobile_agent/permissions/policy.py +288 -0
  657. mobile_agent/permissions/project_policy.py +411 -0
  658. mobile_agent/permissions/register.py +148 -0
  659. mobile_agent/permissions/rules.py +291 -0
  660. mobile_agent/phone_io/__init__.py +146 -0
  661. mobile_agent/phone_io/cli.py +427 -0
  662. mobile_agent/phone_io/clipboard.py +78 -0
  663. mobile_agent/phone_io/controls.py +78 -0
  664. mobile_agent/phone_io/files.py +289 -0
  665. mobile_agent/phone_io/handoff.py +123 -0
  666. mobile_agent/phone_io/icloud.py +118 -0
  667. mobile_agent/phone_io/install.py +188 -0
  668. mobile_agent/phone_io/network_capture.py +188 -0
  669. mobile_agent/phone_io/network_cli.py +138 -0
  670. mobile_agent/phone_io/network_profiles.py +138 -0
  671. mobile_agent/phone_io/photos.py +512 -0
  672. mobile_agent/phone_io/recording.py +115 -0
  673. mobile_agent/phone_io/secrets.py +146 -0
  674. mobile_agent/phone_io/transfer.py +895 -0
  675. mobile_agent/phone_io/tunnel.py +129 -0
  676. mobile_agent/phone_ready.py +91 -0
  677. mobile_agent/pinning.py +158 -0
  678. mobile_agent/plan_gate.py +807 -0
  679. mobile_agent/plugins/__init__.py +3 -0
  680. mobile_agent/plugins/install.py +163 -0
  681. mobile_agent/plugins/manifest.py +242 -0
  682. mobile_agent/plugins/register.py +15 -0
  683. mobile_agent/plugins/routes.py +82 -0
  684. mobile_agent/plugins/service.py +322 -0
  685. mobile_agent/plugins/skills.py +76 -0
  686. mobile_agent/plugins/store.py +79 -0
  687. mobile_agent/pool.py +285 -0
  688. mobile_agent/power_watch.py +275 -0
  689. mobile_agent/premium_model.py +22 -0
  690. mobile_agent/project_env.py +291 -0
  691. mobile_agent/project_env_cli.py +74 -0
  692. mobile_agent/project_env_routes.py +55 -0
  693. mobile_agent/projects/__init__.py +27 -0
  694. mobile_agent/projects/ci.py +332 -0
  695. mobile_agent/projects/clone.py +339 -0
  696. mobile_agent/projects/drafts.py +158 -0
  697. mobile_agent/projects/folder.py +436 -0
  698. mobile_agent/projects/github.py +131 -0
  699. mobile_agent/projects/register.py +31 -0
  700. mobile_agent/projects/revisions.py +253 -0
  701. mobile_agent/projects/routes.py +234 -0
  702. mobile_agent/projects/runs.py +297 -0
  703. mobile_agent/projects/service.py +695 -0
  704. mobile_agent/projects/setup.py +84 -0
  705. mobile_agent/projects/store.py +379 -0
  706. mobile_agent/projects/sync.py +380 -0
  707. mobile_agent/projects/workflow_files.py +270 -0
  708. mobile_agent/replay_core/__init__.py +26 -0
  709. mobile_agent/replay_core/checks.py +594 -0
  710. mobile_agent/replay_core/engine.py +596 -0
  711. mobile_agent/replay_core/heal.py +364 -0
  712. mobile_agent/replay_core/path.py +240 -0
  713. mobile_agent/replay_core/recorder.py +206 -0
  714. mobile_agent/replay_core/store.py +171 -0
  715. mobile_agent/result_blocks.py +941 -0
  716. mobile_agent/run.py +642 -0
  717. mobile_agent/run_delivery.py +123 -0
  718. mobile_agent/run_executor.py +200 -0
  719. mobile_agent/run_export.py +284 -0
  720. mobile_agent/run_gif.py +891 -0
  721. mobile_agent/runner_hints.py +86 -0
  722. mobile_agent/runner_renewal.py +205 -0
  723. mobile_agent/runtime_create.py +549 -0
  724. mobile_agent/runtime_driver.py +458 -0
  725. mobile_agent/runtime_limits.py +319 -0
  726. mobile_agent/runtime_queue.py +394 -0
  727. mobile_agent/runtime_runs.py +383 -0
  728. mobile_agent/runtime_status.py +181 -0
  729. mobile_agent/runtime_teach.py +295 -0
  730. mobile_agent/runtime_work.py +599 -0
  731. mobile_agent/screen_text.py +101 -0
  732. mobile_agent/secret_filter.py +113 -0
  733. mobile_agent/serve.py +160 -0
  734. mobile_agent/server.py +363 -0
  735. mobile_agent/service_support.py +42 -0
  736. mobile_agent/settle/__init__.py +1 -0
  737. mobile_agent/settle/predict.py +102 -0
  738. mobile_agent/settle/replay.py +92 -0
  739. mobile_agent/settle/store.py +93 -0
  740. mobile_agent/settle/stream.py +43 -0
  741. mobile_agent/setup_cli.py +225 -0
  742. mobile_agent/setup_errors.py +87 -0
  743. mobile_agent/setup_service.py +527 -0
  744. mobile_agent/share.py +104 -0
  745. mobile_agent/signing.py +184 -0
  746. mobile_agent/sim/__init__.py +6 -0
  747. mobile_agent/sim/__main__.py +26 -0
  748. mobile_agent/sim/api.py +952 -0
  749. mobile_agent/sim/apps.py +172 -0
  750. mobile_agent/sim/biometrics.py +84 -0
  751. mobile_agent/sim/cli.py +271 -0
  752. mobile_agent/sim/doctor.py +293 -0
  753. mobile_agent/sim/governor.py +90 -0
  754. mobile_agent/sim/holders.py +88 -0
  755. mobile_agent/sim/models.py +62 -0
  756. mobile_agent/sim/registry.py +161 -0
  757. mobile_agent/sim/simctl.py +269 -0
  758. mobile_agent/sim/wda.py +618 -0
  759. mobile_agent/sim/xcode.py +74 -0
  760. mobile_agent/skills/__init__.py +12 -0
  761. mobile_agent/skills/codes.py +597 -0
  762. mobile_agent/skills/notifications.py +281 -0
  763. mobile_agent/skills/open_url.py +127 -0
  764. mobile_agent/smart_events.py +588 -0
  765. mobile_agent/smart_result.py +490 -0
  766. mobile_agent/smart_run.py +413 -0
  767. mobile_agent/state.py +662 -0
  768. mobile_agent/states.py +217 -0
  769. mobile_agent/steering.py +70 -0
  770. mobile_agent/style.py +178 -0
  771. mobile_agent/supervised.py +234 -0
  772. mobile_agent/swift/helper_bt_hid.swift +534 -0
  773. mobile_agent/swift/helper_doc_text.swift +184 -0
  774. mobile_agent/swift/helper_draft_reply.swift +170 -0
  775. mobile_agent/swift/helper_map.swift +110 -0
  776. mobile_agent/swift/helper_mirroring.swift +1099 -0
  777. mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
  778. mobile_agent/swift/helper_pdf.swift +100 -0
  779. mobile_agent/swift/helper_phone_audio.swift +993 -0
  780. mobile_agent/swift/helper_phone_screen.swift +416 -0
  781. mobile_agent/swift/helper_screen_text.swift +24 -0
  782. mobile_agent/swift/helper_transfer.swift +246 -0
  783. mobile_agent/swift/ocr.swift +22 -0
  784. mobile_agent/task_policy.py +337 -0
  785. mobile_agent/team_signing/__init__.py +34 -0
  786. mobile_agent/team_signing/apple_ca.py +37 -0
  787. mobile_agent/team_signing/bundle.py +136 -0
  788. mobile_agent/team_signing/cli.py +208 -0
  789. mobile_agent/team_signing/identity.py +248 -0
  790. mobile_agent/team_signing/ledger.py +105 -0
  791. mobile_agent/team_signing/prebuilt.py +61 -0
  792. mobile_agent/team_signing/resign.py +256 -0
  793. mobile_agent/team_signing/service.py +382 -0
  794. mobile_agent/team_signing/tooling.py +243 -0
  795. mobile_agent/team_signing/writes.py +268 -0
  796. mobile_agent/terminal_image.py +81 -0
  797. mobile_agent/testkit/__init__.py +1 -0
  798. mobile_agent/testkit/a11y_baseline.py +132 -0
  799. mobile_agent/testkit/api.py +403 -0
  800. mobile_agent/testkit/capsule.py +400 -0
  801. mobile_agent/testkit/capsule_cli.py +57 -0
  802. mobile_agent/testkit/cli.py +948 -0
  803. mobile_agent/testkit/cli_arguments.py +193 -0
  804. mobile_agent/testkit/comparison.py +288 -0
  805. mobile_agent/testkit/comparison_cli.py +41 -0
  806. mobile_agent/testkit/discover.py +257 -0
  807. mobile_agent/testkit/editor.py +243 -0
  808. mobile_agent/testkit/execute.py +515 -0
  809. mobile_agent/testkit/github_checks.py +248 -0
  810. mobile_agent/testkit/gitref.py +66 -0
  811. mobile_agent/testkit/heal_review.py +176 -0
  812. mobile_agent/testkit/health.py +480 -0
  813. mobile_agent/testkit/history.py +255 -0
  814. mobile_agent/testkit/hosted_results.py +321 -0
  815. mobile_agent/testkit/input_files.py +173 -0
  816. mobile_agent/testkit/jobs.py +285 -0
  817. mobile_agent/testkit/junit_verdict.py +33 -0
  818. mobile_agent/testkit/maestro.py +802 -0
  819. mobile_agent/testkit/mcp.py +562 -0
  820. mobile_agent/testkit/mcp_comparison.py +88 -0
  821. mobile_agent/testkit/minimize.py +471 -0
  822. mobile_agent/testkit/minimizer_cli.py +48 -0
  823. mobile_agent/testkit/notify.py +212 -0
  824. mobile_agent/testkit/phone_video.py +191 -0
  825. mobile_agent/testkit/pr.py +491 -0
  826. mobile_agent/testkit/pr_report.py +562 -0
  827. mobile_agent/testkit/prbuild.py +148 -0
  828. mobile_agent/testkit/proof_report.py +55 -0
  829. mobile_agent/testkit/provenance.py +119 -0
  830. mobile_agent/testkit/prrun.py +244 -0
  831. mobile_agent/testkit/recent.py +166 -0
  832. mobile_agent/testkit/recipes.py +146 -0
  833. mobile_agent/testkit/record.py +204 -0
  834. mobile_agent/testkit/register.py +288 -0
  835. mobile_agent/testkit/reports.py +998 -0
  836. mobile_agent/testkit/repro.py +432 -0
  837. mobile_agent/testkit/scenario.py +440 -0
  838. mobile_agent/testkit/scenario_cli.py +37 -0
  839. mobile_agent/testkit/snapshots_cli.py +60 -0
  840. mobile_agent/testkit/suite.py +670 -0
  841. mobile_agent/testkit/targets.py +497 -0
  842. mobile_agent/testkit/xctest.py +378 -0
  843. mobile_agent/threads/__init__.py +1 -0
  844. mobile_agent/threads/chat_once.py +264 -0
  845. mobile_agent/threads/cli.py +740 -0
  846. mobile_agent/threads/client.py +326 -0
  847. mobile_agent/threads/context.py +246 -0
  848. mobile_agent/threads/mcp.py +739 -0
  849. mobile_agent/threads/recent.py +34 -0
  850. mobile_agent/threads/register.py +54 -0
  851. mobile_agent/threads/routes.py +188 -0
  852. mobile_agent/threads/service.py +975 -0
  853. mobile_agent/threads/store.py +361 -0
  854. mobile_agent/threads/summary.py +117 -0
  855. mobile_agent/threads/term.py +101 -0
  856. mobile_agent/threads/workflow_cli.py +104 -0
  857. mobile_agent/tls.py +36 -0
  858. mobile_agent/traces.py +271 -0
  859. mobile_agent/tracks.py +235 -0
  860. mobile_agent/transport.py +271 -0
  861. mobile_agent/triggers/__init__.py +1 -0
  862. mobile_agent/triggers/actions.py +204 -0
  863. mobile_agent/triggers/model.py +146 -0
  864. mobile_agent/triggers/register.py +29 -0
  865. mobile_agent/triggers/routes.py +101 -0
  866. mobile_agent/triggers/service.py +387 -0
  867. mobile_agent/triggers/store.py +142 -0
  868. mobile_agent/tui/__init__.py +89 -0
  869. mobile_agent/tui/app.py +582 -0
  870. mobile_agent/tui/chrome.py +321 -0
  871. mobile_agent/tui/commands.py +375 -0
  872. mobile_agent/tui/demo_phone.py +369 -0
  873. mobile_agent/tui/images.py +29 -0
  874. mobile_agent/tui/keys.py +64 -0
  875. mobile_agent/tui/prompt.py +195 -0
  876. mobile_agent/tui/render.py +395 -0
  877. mobile_agent/tui/screens.py +180 -0
  878. mobile_agent/tui/session.py +308 -0
  879. mobile_agent/tui/tasks.py +287 -0
  880. mobile_agent/tui/theme.py +118 -0
  881. mobile_agent/tui/widgets.py +272 -0
  882. mobile_agent/update_check.py +213 -0
  883. mobile_agent/usage.py +184 -0
  884. mobile_agent/verify/__init__.py +0 -0
  885. mobile_agent/verify/__main__.py +41 -0
  886. mobile_agent/verify/a11y.py +189 -0
  887. mobile_agent/verify/actions.py +290 -0
  888. mobile_agent/verify/assertions.py +570 -0
  889. mobile_agent/verify/audit.py +312 -0
  890. mobile_agent/verify/business.py +221 -0
  891. mobile_agent/verify/business_run.py +131 -0
  892. mobile_agent/verify/business_state.py +168 -0
  893. mobile_agent/verify/check_schema.py +398 -0
  894. mobile_agent/verify/checks.py +614 -0
  895. mobile_agent/verify/cli.py +423 -0
  896. mobile_agent/verify/conditions.py +483 -0
  897. mobile_agent/verify/crashes.py +321 -0
  898. mobile_agent/verify/device_audit.py +246 -0
  899. mobile_agent/verify/dialogs.py +287 -0
  900. mobile_agent/verify/evidence.py +705 -0
  901. mobile_agent/verify/fixtures.py +305 -0
  902. mobile_agent/verify/flow.py +213 -0
  903. mobile_agent/verify/l10n.py +141 -0
  904. mobile_agent/verify/netcerts.py +67 -0
  905. mobile_agent/verify/netproxy.py +356 -0
  906. mobile_agent/verify/netrun.py +81 -0
  907. mobile_agent/verify/netshim.m +28 -0
  908. mobile_agent/verify/netshim.py +48 -0
  909. mobile_agent/verify/netstubs.py +132 -0
  910. mobile_agent/verify/overlay.py +174 -0
  911. mobile_agent/verify/perf.py +239 -0
  912. mobile_agent/verify/proof.py +192 -0
  913. mobile_agent/verify/replay_admission.py +48 -0
  914. mobile_agent/verify/report.py +952 -0
  915. mobile_agent/verify/run_cleanup.py +67 -0
  916. mobile_agent/verify/run_links.py +48 -0
  917. mobile_agent/verify/run_perf.py +44 -0
  918. mobile_agent/verify/run_secrets.py +39 -0
  919. mobile_agent/verify/runner.py +976 -0
  920. mobile_agent/verify/runs.py +57 -0
  921. mobile_agent/verify/screen_coverage.py +181 -0
  922. mobile_agent/verify/smart.py +106 -0
  923. mobile_agent/verify/snapshot_run.py +114 -0
  924. mobile_agent/verify/snapshots.py +306 -0
  925. mobile_agent/verify/sound.py +335 -0
  926. mobile_agent/verify/source_observation.py +130 -0
  927. mobile_agent/verify/system_surfaces/__init__.py +7 -0
  928. mobile_agent/verify/system_surfaces/play.py +106 -0
  929. mobile_agent/verify/system_surfaces/spec.py +139 -0
  930. mobile_agent/verify/system_surfaces/stage.py +138 -0
  931. mobile_agent/verify/system_surfaces/surfaces.py +121 -0
  932. mobile_agent/verify/tree.py +223 -0
  933. mobile_agent/verify/verdict.py +377 -0
  934. mobile_agent/verify/wire.py +392 -0
  935. mobile_agent/verify/wirestore.py +176 -0
  936. mobile_agent/waiting/__init__.py +1 -0
  937. mobile_agent/waiting/contacts.py +73 -0
  938. mobile_agent/waiting/drafts.py +136 -0
  939. mobile_agent/waiting/job.py +145 -0
  940. mobile_agent/waiting/messages.py +174 -0
  941. mobile_agent/waiting/rank.py +115 -0
  942. mobile_agent/waiting/register.py +26 -0
  943. mobile_agent/watch.py +502 -0
  944. mobile_agent/wda/__init__.py +2 -0
  945. mobile_agent/wda/frame_clock.py +646 -0
  946. mobile_agent/wda/frame_source.py +325 -0
  947. mobile_agent/wda/frame_store.py +115 -0
  948. mobile_agent/wda/gate.py +163 -0
  949. mobile_agent/wda/gestures.py +291 -0
  950. mobile_agent/wda/guards.py +94 -0
  951. mobile_agent/wda/identity.py +55 -0
  952. mobile_agent/wda/observe.py +884 -0
  953. mobile_agent/wda/patches.py +856 -0
  954. mobile_agent/wda/point_gestures.py +150 -0
  955. mobile_agent/wda/source.py +32 -0
  956. mobile_agent/wda/system_dialogs.py +571 -0
  957. mobile_agent/wda/text.py +694 -0
  958. mobile_agent/wda/timeouts.py +77 -0
  959. mobile_agent/wda/video.py +505 -0
  960. mobile_agent/wda/watchdog.py +243 -0
  961. mobile_agent/wireless/__init__.py +15 -0
  962. mobile_agent/wireless/api.py +354 -0
  963. mobile_agent/wireless/cli.py +287 -0
  964. mobile_agent/wireless/devicectl.py +229 -0
  965. mobile_agent/wireless/exposure.py +33 -0
  966. mobile_agent/wireless/mobster-wifi-pair.c +122 -0
  967. mobile_agent/wireless/monitor.py +332 -0
  968. mobile_agent/wireless/network.py +334 -0
  969. mobile_agent/wireless/onboarding.py +293 -0
  970. mobile_agent/wireless/pair.py +124 -0
  971. mobile_agent/wireless/probe.py +322 -0
  972. mobile_agent/wireless/register.py +8 -0
  973. mobile_agent/wireless/relay.py +179 -0
  974. mobile_agent/wireless/result.py +87 -0
  975. mobile_agent/wireless/store.py +85 -0
  976. mobile_agent/wireless/switch.py +77 -0
  977. mobile_agent/wireless/transport.py +118 -0
  978. mobile_agent/wireless/words.py +81 -0
  979. mobile_agent/wireless/xctestrun.py +83 -0
  980. mobile_agent/workflow_tools.py +59 -0
  981. mobile_agent/workflows/__init__.py +2 -0
  982. mobile_agent/workflows/brief.py +201 -0
  983. mobile_agent/workflows/cron.py +110 -0
  984. mobile_agent/workflows/health.py +257 -0
  985. mobile_agent/workflows/history.py +126 -0
  986. mobile_agent/workflows/records.py +442 -0
  987. mobile_agent/workflows/runs.py +1000 -0
  988. mobile_agent/workflows/scheduler.py +134 -0
  989. mobile_agent/workflows/service.py +973 -0
  990. mobile_agent/workflows/starters.py +285 -0
  991. mobile_agent/workflows/triggers.py +248 -0
  992. mobile_agent/xray/__init__.py +1 -0
  993. mobile_agent/xray/app_cli.py +116 -0
  994. mobile_agent/xray/cli.py +21 -0
  995. mobile_agent/xray/crawl.py +754 -0
  996. mobile_agent/xray/diff.py +171 -0
  997. mobile_agent/xray/diff_cli.py +43 -0
  998. mobile_agent/xray/job.py +184 -0
  999. mobile_agent/xray/paywall.py +188 -0
  1000. mobile_agent/xray/policy.py +305 -0
  1001. mobile_agent/xray/register.py +43 -0
  1002. mobile_agent/xray/report.py +283 -0
  1003. mobile_agent/xray/screens.py +117 -0
  1004. mobster_cli-0.4.3.dist-info/METADATA +93 -0
  1005. mobster_cli-0.4.3.dist-info/RECORD +1009 -0
  1006. mobster_cli-0.4.3.dist-info/WHEEL +5 -0
  1007. mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
  1008. mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
  1009. mobster_cli-0.4.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,737 @@
1
+ """`mobster ci`: your checks on every pull request or merge request, on a Mac of yours or a hosted CI runner."""
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from pathlib import Path
14
+
15
+ from .. import __version__, plan_gate, premium_model
16
+ from . import DevError, self_argv
17
+ from .ci_hosts import COMMAND
18
+
19
+ CI_DIR = ".mobster/ci"
20
+ MARKER = "<!-- mobster:test-pr -->"
21
+ SCHEMA = "mobster.ci/1"
22
+ GITHUB_WORKFLOW = ".github/workflows/mobster.yml"
23
+ GITLAB_FILE = ".gitlab-ci.yml"
24
+ GITLAB_INCLUDE = ".gitlab/mobster.gitlab-ci.yml"
25
+ # Hosts whose runners Mobster doesn't see: every job on them is a hosted macOS runner, with no phones.
26
+ HOSTED_HOSTS = ("xcode-cloud", "codemagic", "bitrise", "circleci")
27
+ HOST_FILES = {"xcode-cloud": "ci_scripts/ci_post_xcodebuild.sh", "codemagic": "codemagic.yaml",
28
+ "bitrise": "bitrise.yml", "circleci": ".circleci/config.yml"}
29
+ SNIPPETS = ".mobster/hosts"
30
+ PAGE = 100
31
+ MAX_PAGES = 30
32
+ MIN_FREE_GB, LOW_FREE_GB = 10, 30
33
+ COMMENT_LIMIT = 60_000
34
+ DOCS = "https://docs.mobster.dev"
35
+
36
+
37
+ def listed(text):
38
+ """A comma- or newline-separated list, folded for comparison."""
39
+ return {item.strip().casefold() for item in re.split(r"[,\n]", text or "") if item.strip()}
40
+
41
+
42
+ def host_of(environ=None):
43
+ """The CI host of this job ("github", "gitlab", "xcode-cloud", "codemagic", "bitrise", "circleci"), else None (a
44
+ terminal)."""
45
+ environ = os.environ if environ is None else environ
46
+ if environ.get("GITHUB_ACTIONS") == "true":
47
+ return "github"
48
+ if environ.get("GITLAB_CI") == "true":
49
+ return "gitlab"
50
+ if environ.get("CI_XCODE_CLOUD") == "TRUE":
51
+ return "xcode-cloud"
52
+ if environ.get("CM_BUILD_ID"):
53
+ return "codemagic"
54
+ if environ.get("BITRISE_IO") == "true":
55
+ return "bitrise"
56
+ if environ.get("CIRCLECI") == "true":
57
+ return "circleci"
58
+ return None
59
+
60
+
61
+ def is_hosted(environ=None):
62
+ """Whether this job runs on a runner the CI host owns, not on a Mac of yours."""
63
+ environ = os.environ if environ is None else environ
64
+ return host_of(environ) in HOSTED_HOSTS or environ.get("RUNNER_ENVIRONMENT") == "github-hosted"
65
+
66
+
67
+ def remote_host(url):
68
+ """"github" or "gitlab" from a remote URL, else None."""
69
+ text = str(url or "").lower()
70
+ if "github" in text:
71
+ return "github"
72
+ if "gitlab" in text:
73
+ return "gitlab"
74
+ return None
75
+
76
+
77
+ def _git(root, *args, runner=None):
78
+ run = runner or (lambda argv, cwd: subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=60))
79
+ try:
80
+ done = run(["git", *args], str(root))
81
+ except (OSError, subprocess.SubprocessError):
82
+ return 1, ""
83
+ return done.returncode, (done.stdout or "").strip()
84
+
85
+
86
+ def repo_root(start, runner=None):
87
+ code, out = _git(start, "rev-parse", "--show-toplevel", runner=runner)
88
+ return Path(out) if code == 0 and out else Path(start)
89
+
90
+
91
+ # -- init --------------------------------------------------------------------------------------------------------
92
+
93
+ def _flags(devices, testflight):
94
+ return "".join(f" --device {shlex.quote(device)}" for device in devices) + \
95
+ "".join(f" --testflight {shlex.quote(bundle)}" for bundle in testflight)
96
+
97
+
98
+ def github_workflow(*, devices=(), testflight=()):
99
+ flags = _flags(devices, testflight)
100
+ return f"""# Mobster checks on every pull request, on a Mac of yours ({DOCS}/ci).
101
+ # Install uv on the runner (`brew install uv`). `mobster ci doctor` says what else is missing.
102
+ name: mobster
103
+ on: pull_request
104
+ permissions:
105
+ contents: read
106
+ pull-requests: write # the one comment Mobster keeps up to date
107
+ concurrency:
108
+ group: mobster-${{{{ github.event.pull_request.number }}}}
109
+ cancel-in-progress: true
110
+ jobs:
111
+ checks:
112
+ runs-on: [self-hosted, macOS, mobster]
113
+ timeout-minutes: 60
114
+ steps:
115
+ - uses: actions/checkout@v4
116
+ with:
117
+ fetch-depth: 0 # the diff needs the base branch
118
+ - name: Test what this PR changed
119
+ run: |
120
+ {COMMAND} ci run{flags}
121
+ env:
122
+ ANTHROPIC_API_KEY: ${{{{ secrets.ANTHROPIC_API_KEY }}}} # only checks with steps use it
123
+ - name: Upload the report
124
+ id: upload
125
+ if: always()
126
+ uses: actions/upload-artifact@v4
127
+ with:
128
+ name: mobster-report
129
+ path: .mobster/ci/
130
+ include-hidden-files: true
131
+ retention-days: 14
132
+ if-no-files-found: ignore
133
+ - name: Comment on the PR
134
+ if: always()
135
+ run: |
136
+ {COMMAND} ci comment
137
+ env:
138
+ GITHUB_TOKEN: ${{{{ github.token }}}}
139
+ MOBSTER_ARTIFACT_URL: ${{{{ steps.upload.outputs.artifact-url }}}}
140
+ """
141
+
142
+
143
+ def gitlab_job(*, devices=(), testflight=()):
144
+ flags = _flags(devices, testflight)
145
+ return f"""# Mobster checks on every merge request, on a Mac of yours ({DOCS}/ci/gitlab).
146
+ # Register the Mac as a runner with the shell executor and the tag "mobster". On it, `mobster ci doctor` says what's
147
+ # missing. Install uv with `brew install uv`. For the MR comment, add MOBSTER_GITLAB_TOKEN (a project access token
148
+ # with the api scope) as a masked CI/CD variable. For checks with steps, add ANTHROPIC_API_KEY the same way.
149
+ mobster:
150
+ stage: test
151
+ tags: [mobster]
152
+ rules:
153
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
154
+ variables:
155
+ GIT_DEPTH: "0"
156
+ timeout: 1h
157
+ interruptible: true
158
+ script:
159
+ - |
160
+ {COMMAND} ci run{flags}
161
+ after_script:
162
+ - |
163
+ {COMMAND} ci comment
164
+ artifacts:
165
+ when: always
166
+ expire_in: 14 days
167
+ paths:
168
+ - .mobster/ci/
169
+ reports:
170
+ junit: .mobster/ci/junit.xml
171
+ """
172
+
173
+
174
+ def workflow(host, *, devices=(), testflight=(), hosted=False):
175
+ """(the file's path in the repository, its text): what `ci init` writes and `ci init --print` prints. Raises
176
+ DevError.
177
+
178
+ ``hosted`` means GitHub's hosted macOS runner; the other hosted CI hosts are always hosted."""
179
+ from . import ci_hosts
180
+ if host in HOSTED_HOSTS or (hosted and host == "github"):
181
+ if devices or testflight:
182
+ raise DevError("A hosted runner has no iPhone to test on, so --device and --testflight don't go with it.",
183
+ "Drop them, or use a Mac of yours as the runner.")
184
+ return Path(GITHUB_WORKFLOW if host == "github" else HOST_FILES[host]), ci_hosts.render(host)
185
+ if hosted and host == "gitlab":
186
+ raise DevError("Mobster has no hosted runner setup for GitLab CI yet.",
187
+ "Use a Mac of yours as the runner: https://docs.mobster.dev/ci/gitlab")
188
+ if host == "github":
189
+ return Path(GITHUB_WORKFLOW), github_workflow(devices=devices, testflight=testflight)
190
+ if host == "gitlab":
191
+ return Path(GITLAB_FILE), gitlab_job(devices=devices, testflight=testflight)
192
+ raise DevError("Mobster can't tell where this repository lives.", "Pass --host github or --host gitlab.")
193
+
194
+
195
+ def init(root, *, host, devices=(), testflight=(), force=False, hosted=False):
196
+ """Write the workflow. Returns (path written, extra lines to tell the person). Raises DevError."""
197
+ root = Path(root)
198
+ relative, text = workflow(host, devices=devices, testflight=testflight, hosted=hosted)
199
+ target, notes = root / relative, []
200
+ if host in HOSTED_HOSTS and host != "xcode-cloud" and target.exists() and not force:
201
+ # these files hold a project's other jobs: leave them and put ours beside them to merge
202
+ target = root / SNIPPETS / relative.name
203
+ notes.append(f"{relative} already exists, so Mobster wrote the job to {target.relative_to(root)}. "
204
+ f"Merge it into {relative}.")
205
+ elif host == "gitlab" and target.exists():
206
+ target = root / GITLAB_INCLUDE
207
+ notes.append(f"{GITLAB_FILE} already exists, so the job went to {GITLAB_INCLUDE}. Add it to "
208
+ f"{GITLAB_FILE}:\n\n include:\n - local: {GITLAB_INCLUDE}\n")
209
+ if target.exists() and not force:
210
+ raise DevError(f"{target.relative_to(root)} already exists, so Mobster left it as it is.",
211
+ "Pass --force to replace it.")
212
+ target.parent.mkdir(parents=True, exist_ok=True)
213
+ target.write_text(text, encoding="utf-8")
214
+ if text.startswith("#!"):
215
+ target.chmod(0o755)
216
+ return target, notes
217
+
218
+
219
+ # -- doctor ------------------------------------------------------------------------------------------------------
220
+
221
+ class Row:
222
+ def __init__(self, name, status, detail, fix=""):
223
+ self.name, self.status, self.detail, self.fix = name, status, detail, fix
224
+
225
+ def public(self):
226
+ return {"name": self.name, "status": self.status, "detail": self.detail, "fix": self.fix or None}
227
+
228
+
229
+ def _run(argv, timeout=30):
230
+ try:
231
+ done = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
232
+ except (OSError, subprocess.SubprocessError):
233
+ return 127, ""
234
+ return done.returncode, (done.stdout or "") + (done.stderr or "")
235
+
236
+
237
+ def pmset_value(text, name):
238
+ """The number pmset -g prints for ``name`` (" sleep 0"), or None."""
239
+ match = re.search(rf"^\s*{re.escape(name)}\s+(\d+)", text or "", re.MULTILINE)
240
+ return int(match.group(1)) if match else None
241
+
242
+
243
+ def runner_env(home, environ=None):
244
+ environ = os.environ if environ is None else environ
245
+ found = {key: environ[key] for key in ("MOBSTER_RUNNER_DEVICES", "MOBSTER_RUNNER_TESTFLIGHT") if environ.get(key)}
246
+ if found:
247
+ return found, "the job's environment"
248
+ env_file = Path(home) / "actions-runner" / ".env"
249
+ try:
250
+ for line in env_file.read_text(encoding="utf-8").splitlines():
251
+ key, _, value = line.partition("=")
252
+ if key.strip() in ("MOBSTER_RUNNER_DEVICES", "MOBSTER_RUNNER_TESTFLIGHT"):
253
+ found[key.strip()] = value.strip()
254
+ if found:
255
+ return found, str(env_file)
256
+ except OSError:
257
+ pass
258
+ config = Path(home) / ".gitlab-runner" / "config.toml"
259
+ try:
260
+ text = config.read_text(encoding="utf-8")
261
+ for key in ("MOBSTER_RUNNER_DEVICES", "MOBSTER_RUNNER_TESTFLIGHT"):
262
+ match = re.search(rf'"{key}=([^"]*)"', text)
263
+ if match:
264
+ found[key] = match.group(1)
265
+ if found:
266
+ return found, str(config)
267
+ except OSError:
268
+ pass
269
+ return {}, None
270
+
271
+
272
+ def doctor(*, run=_run, home=None, environ=None, machine=None, plan=None, agent=None, disk=None, hosted=None,
273
+ ci_token=None):
274
+ home = Path(home or Path.home())
275
+ environ = os.environ if environ is None else environ
276
+ hosted = is_hosted(environ) if hosted is None else hosted
277
+ rows = []
278
+ arch = machine if machine is not None else platform.machine()
279
+ rows.append(Row("Apple silicon", "ok" if arch == "arm64" else "warn", f"this Mac is {arch or 'unknown'}",
280
+ "" if arch == "arm64" else "Simulators run fastest on Apple silicon; an Intel Mac works, slower."))
281
+ code, out = run(["xcodebuild", "-version"])
282
+ version = out.splitlines()[0].strip() if code == 0 and out.strip() else ""
283
+ rows.append(Row("Xcode", "ok" if version else "fail", version or "xcodebuild didn't answer",
284
+ "" if version else "Install Xcode from the App Store, open it once, then run "
285
+ "`sudo xcode-select -s /Applications/Xcode.app`."))
286
+ code, out = run(["xcrun", "simctl", "list", "runtimes", "-j"])
287
+ runtimes = []
288
+ try:
289
+ runtimes = [item.get("name") for item in json.loads(out).get("runtimes") or ()
290
+ if item.get("isAvailable") and str(item.get("name", "")).startswith("iOS")]
291
+ except (ValueError, AttributeError):
292
+ pass
293
+ rows.append(Row("iOS Simulator runtime", "ok" if runtimes else "fail",
294
+ ", ".join(runtimes[-2:]) if runtimes else "none installed",
295
+ "" if runtimes else "Run `xcodebuild -downloadPlatform iOS`."))
296
+ agent = self_argv()[0][0] if agent is None else agent
297
+ installed = Path(agent).is_file()
298
+ rows.append(Row("Mobster CLI", "ok" if installed else "fail",
299
+ f"version {__version__}" if installed else "executable not found",
300
+ "" if installed else f"Install uv, then run `{COMMAND} ci doctor`."))
301
+ bundled = plan_gate.bundled()
302
+ has_token = bool(environ.get("MOBSTER_CI_TOKEN", "").strip())
303
+ if hosted or ci_token is not None or has_token:
304
+ if ci_token is not None:
305
+ token_row = _token_row(ci_token)
306
+ elif bundled or has_token:
307
+ token_row = hosted_token_row(environ)
308
+ else:
309
+ token_row = Row("CI token", "skip", "not required by the standalone CLI")
310
+ rows.append(token_row)
311
+ if not hosted:
312
+ if bundled or plan is not None:
313
+ problem = (plan or _plan_problem)()
314
+ rows.append(Row("Mobster plan", "ok" if problem is None else "fail",
315
+ "active on this Mac" if problem is None else f"none active ({problem.replace('_', ' ')})",
316
+ "" if problem is None else "Open Mobster, sign in, and start your free trial or choose a "
317
+ "plan."))
318
+ else:
319
+ rows.append(Row("Mobster plan", "skip", "not required by the standalone CLI"))
320
+ if hosted:
321
+ # a hosted runner is a fresh machine for every job, so the rows about keeping one running don't apply
322
+ for name in ("System sleep", "Restart after a power cut", "Automatic login", "Runner service"):
323
+ rows.append(Row(name, "skip", "not needed on a hosted runner"))
324
+ return rows + _hosted_tail(home, disk)
325
+ code, out = run(["pmset", "-g"])
326
+ sleep, restart = pmset_value(out, "sleep"), pmset_value(out, "autorestart")
327
+ rows.append(Row("System sleep", "ok" if sleep == 0 else "fail" if sleep else "warn",
328
+ "never" if sleep == 0 else f"after {sleep} minutes" if sleep else "unknown",
329
+ "" if sleep == 0 else "Run `sudo pmset -a sleep 0 disksleep 0`, so the Mac never sleeps through "
330
+ "a job."))
331
+ rows.append(Row("Restart after a power cut", "ok" if restart == 1 else "warn",
332
+ "on" if restart == 1 else "off",
333
+ "" if restart == 1 else "Run `sudo pmset -a autorestart 1`."))
334
+ code, out = run(["defaults", "read", "/Library/Preferences/com.apple.loginwindow", "autoLoginUser"])
335
+ user = out.strip() if code == 0 else ""
336
+ rows.append(Row("Automatic login", "ok" if user else "warn", f"as {user}" if user else "off",
337
+ "" if user else "Turn it on in System Settings › Users & Groups, so the runner and the "
338
+ "simulators start again after a restart."))
339
+ code, out = run(["launchctl", "list"])
340
+ services = [name for name, needle in (("GitHub Actions runner", "actions.runner"),
341
+ ("GitLab Runner", "gitlab-runner")) if needle in out]
342
+ rows.append(Row("Runner service", "ok" if services else "warn", ", ".join(services) or "none running",
343
+ "" if services else f"Install the runner as a service of this user: {DOCS}/ci/mac-runner."))
344
+ free = (disk or (lambda path: shutil.disk_usage(path).free))(str(home))
345
+ gigabytes = free / 1e9
346
+ rows.append(Row("Free disk", "ok" if gigabytes >= LOW_FREE_GB else "warn" if gigabytes >= MIN_FREE_GB else
347
+ "fail", f"{gigabytes:.0f} GB",
348
+ "" if gigabytes >= LOW_FREE_GB else "Free some space: `xcrun simctl delete unavailable` and "
349
+ "Xcode's DerivedData are the usual places."))
350
+ values, source = runner_env(home, environ)
351
+ if source and source.startswith(str(home) + os.sep):
352
+ source = "~" + source[len(str(home)):]
353
+ phones = sorted(listed(values.get("MOBSTER_RUNNER_DEVICES", "")))
354
+ rows.append(Row("Phones this runner allows", "ok",
355
+ f"{', '.join(phones)} (from {source})" if phones else "none: checks run on simulators only",
356
+ "" if phones else "To test on an iPhone, plug it in, set it up in Mobster, and list its name in "
357
+ "MOBSTER_RUNNER_DEVICES in the runner's environment."))
358
+ return rows
359
+
360
+
361
+ TOKEN_FIXES = {
362
+ "off": "CI tokens are off in this Mobster. Update Mobster for Mac, or ask Mobster to turn them on for your team.",
363
+ "no_token": "Add your CI token as the secret MOBSTER_CI_TOKEN and pass it to the step that runs `mobster ci run`.",
364
+ "expired": "Create a new CI token and replace the secret.",
365
+ "inactive": "This token's plan is no longer active. Check your plan, then create a new CI token.",
366
+ "plan_required": "The plan behind this token isn't active. Check your plan, then create a new CI token.",
367
+ "revoked": "This token was revoked. Create a new CI token and replace the secret.",
368
+ }
369
+
370
+
371
+ # The row's word for a verdict that refuses (plan_gate.ci_token_verdict), where its key doesn't read as one.
372
+ VERDICT_DETAILS = {"unchecked": "couldn't be checked", "unsendable": "can't be sent", "plan_required": "plan inactive"}
373
+
374
+
375
+ def hosted_token_row(environ=None):
376
+ """A hosted runner's token row. With premiumModel on no run needs a token, but one that's there must check out."""
377
+ environ = os.environ if environ is None else environ
378
+ if not premium_model.enabled(environ):
379
+ return _token_row(lambda: plan_gate.ci_token_problem(environ))
380
+ verdict = plan_gate.ci_token_verdict(environ)
381
+ if verdict is None:
382
+ return Row("CI token", "skip", "not needed")
383
+ if verdict == "ok":
384
+ return Row("CI token", "ok", "checked with Mobster")
385
+ return Row("CI token", "fail", VERDICT_DETAILS.get(verdict, verdict), plan_gate.ci_token_refusal(verdict))
386
+
387
+
388
+ def _token_row(problem):
389
+ problem = problem()
390
+ if problem is None:
391
+ return Row("CI token", "ok", "valid for simulator runs")
392
+ return Row("CI token", "fail", problem.replace("_", " "),
393
+ TOKEN_FIXES.get(problem,
394
+ "This isn't a CI token Mobster accepts. Create a new one and replace the secret."))
395
+
396
+
397
+ def _hosted_tail(home, disk):
398
+ free = (disk or (lambda path: shutil.disk_usage(path).free))(str(home)) / 1e9
399
+ return [Row("Free disk", "ok" if free >= LOW_FREE_GB else "warn" if free >= MIN_FREE_GB else "fail",
400
+ f"{free:.0f} GB", "" if free >= LOW_FREE_GB else "Pick a larger runner, or free space before the job."),
401
+ Row("Phones this runner allows", "skip", "none: a hosted runner has no iPhone, checks run on simulators")]
402
+
403
+
404
+ def _plan_problem():
405
+ if plan_gate.gate_turned_off():
406
+ return None
407
+ return plan_gate.PlanGate(plan_gate.app_account_file()).problem()
408
+
409
+
410
+ # -- run ---------------------------------------------------------------------------------------------------------
411
+
412
+ def guard(devices, testflight, environ=None):
413
+ """In a CI job, refuse the whole run when one phone or TestFlight app isn't on the runner's own lists."""
414
+ environ = os.environ if environ is None else environ
415
+ # outside a CI job the person at the terminal is the runner's owner
416
+ if host_of(environ) is None:
417
+ return None
418
+ allowed = listed(environ.get("MOBSTER_RUNNER_DEVICES", ""))
419
+ refused = [device for device in devices if device.casefold() not in allowed]
420
+ if refused:
421
+ phones = ("the iPhone " if len(refused) == 1 else "the iPhones ") + ", ".join(f'"{name}"' for name in refused)
422
+ raise DevError(f"This runner doesn't allow {phones}, so nothing ran.",
423
+ "Its owner lists the phones it may test in MOBSTER_RUNNER_DEVICES, in the runner's "
424
+ "environment, which a pull request can't change.")
425
+ missing = sorted({bundle.casefold() for bundle in testflight} -
426
+ listed(environ.get("MOBSTER_RUNNER_TESTFLIGHT", "")))
427
+ if missing:
428
+ raise DevError(f"This runner doesn't allow the TestFlight build of {', '.join(missing)}, so nothing ran.",
429
+ "Its owner lists them in MOBSTER_RUNNER_TESTFLIGHT, in the runner's environment.")
430
+ return None
431
+
432
+
433
+ BASE_VARIABLES = ("GITHUB_BASE_REF", "CI_MERGE_REQUEST_TARGET_BRANCH_NAME", "CI_PULL_REQUEST_TARGET_BRANCH",
434
+ "BITRISEIO_GIT_BRANCH_DEST", "MOBSTER_BASE_BRANCH")
435
+
436
+
437
+ def base_ref(environ=None):
438
+ """(the branch the PR or MR merges into as origin/<branch>, its name), or (None, None)."""
439
+ environ = os.environ if environ is None else environ
440
+ branch = next((environ[name] for name in BASE_VARIABLES if environ.get(name, "").strip()), "")
441
+ branch = branch.strip()
442
+ return (f"origin/{branch}", branch) if branch else (None, None)
443
+
444
+
445
+ def fetch_base(root, branch, git=None):
446
+ code, out = _git(root, "rev-parse", "--is-shallow-repository", runner=git)
447
+ if code == 0 and out == "true":
448
+ _git(root, "fetch", "--quiet", "--no-tags", "--unshallow", "origin", runner=git)
449
+ if branch:
450
+ _git(root, "fetch", "--quiet", "--no-tags", "origin",
451
+ f"+refs/heads/{branch}:refs/remotes/origin/{branch}", runner=git)
452
+
453
+
454
+ def test_argv(*, base=None, devices=(), testflight=(), sims=(), video=True, share=False, extra=()):
455
+ argv = ["test", "pr", "--json"]
456
+ if base:
457
+ argv += ["--base", base]
458
+ for sim in sims:
459
+ argv += ["--sim", sim]
460
+ for device in devices:
461
+ argv += ["--device", device]
462
+ for bundle in testflight:
463
+ argv += ["--testflight", bundle]
464
+ if video:
465
+ argv.append("--video")
466
+ if share:
467
+ argv.append("--share")
468
+ return argv + list(extra)
469
+
470
+
471
+ def _last_json(text):
472
+ for line in reversed((text or "").strip().splitlines()):
473
+ line = line.strip()
474
+ if line.startswith("{"):
475
+ try:
476
+ value = json.loads(line)
477
+ except ValueError:
478
+ continue
479
+ if isinstance(value, dict):
480
+ return value
481
+ return None
482
+
483
+
484
+ def _claim(target):
485
+ """Clear the last job's copy in ``target``; refuse a folder holding files Mobster didn't write (no ci.json)."""
486
+ if target.exists():
487
+ if not (target / "ci.json").is_file() and any(target.iterdir()):
488
+ raise DevError(f"{target} holds files Mobster didn't write, so it left them there.",
489
+ "Move them, then run it again.")
490
+ shutil.rmtree(target)
491
+
492
+
493
+ def collect(root, suite, target):
494
+ """Copy the suite folder to ``target`` (.mobster/ci), replacing the last job's copy."""
495
+ source = Path(root) / ".mobster" / "test-results" / str(suite)
496
+ target = Path(target)
497
+ _claim(target)
498
+ if source.is_dir():
499
+ shutil.copytree(source, target)
500
+ else:
501
+ target.mkdir(parents=True, exist_ok=True)
502
+
503
+
504
+ def set_output(name, value, environ=None):
505
+ path = ((os.environ if environ is None else environ).get("GITHUB_OUTPUT") or "").strip()
506
+ if not path:
507
+ return
508
+ with open(path, "a", encoding="utf-8") as handle:
509
+ handle.write(f"{name}={str(value).splitlines()[0] if str(value) else ''}\n")
510
+
511
+
512
+ def run_checks(root, *, devices=(), testflight=(), sims=(), video=True, base=None, extra=(), environ=None,
513
+ runner=None, git=None, progress=None):
514
+ environ = os.environ if environ is None else environ
515
+ progress = progress or (lambda line: None)
516
+ root = Path(root)
517
+ if any(flag.split("=", 1)[0] in ("--device", "--testflight") for flag in extra):
518
+ raise DevError("This CI job can't pass --device or --testflight after --, so nothing ran.",
519
+ "Use mobster ci run --device and --testflight; the runner's allowlist checks those flags.")
520
+ guard(devices, testflight, environ)
521
+ host = host_of(environ)
522
+ if base is None:
523
+ base, branch = base_ref(environ)
524
+ if branch:
525
+ fetch_base(root, branch, git=git)
526
+ argv, env_extra = self_argv()
527
+ # With the team's upload key, the report goes to the team's hosted reports too, and the comment links it.
528
+ share = bool(environ.get("MOBSTER_TEAM_KEY", "").strip())
529
+ argv = argv + test_argv(base=base, devices=devices, testflight=testflight, sims=sims, video=video, share=share,
530
+ extra=extra)
531
+ progress("$ mobster " + " ".join(argv[len(self_argv()[0]):]))
532
+ if runner is None:
533
+ def runner(argv, cwd, env):
534
+ try:
535
+ done = subprocess.run(argv, cwd=cwd, stdout=subprocess.PIPE, text=True, env=env)
536
+ except FileNotFoundError:
537
+ return 127, ""
538
+ return done.returncode, done.stdout
539
+ code, out = runner(argv, str(root), {**environ, **env_extra})
540
+ pr = _last_json(out)
541
+ target = root / CI_DIR
542
+ doc = {"schema": SCHEMA, "host": host, "exitCode": code, "suite": None,
543
+ "headline": "Mobster checks couldn't run"}
544
+ if isinstance(pr, dict) and pr.get("suite"):
545
+ doc.update(suite=pr["suite"], headline=pr.get("headline") or doc["headline"])
546
+ collect(root, pr["suite"], target)
547
+ else:
548
+ _claim(target)
549
+ target.mkdir(parents=True, exist_ok=True)
550
+ (target / "ci.json").write_text(json.dumps(doc, indent=1) + "\n", encoding="utf-8")
551
+ set_output("exit-code", code, environ)
552
+ set_output("headline", doc["headline"], environ)
553
+ return code, doc
554
+
555
+
556
+ # -- comment -----------------------------------------------------------------------------------------------------
557
+
558
+ def links(host, environ=None, artifact_url=None):
559
+ """(the report's link, a function from a file's path in the report to its link, or None)."""
560
+ environ = os.environ if environ is None else environ
561
+ if host == "gitlab":
562
+ job, project, job_id = (environ.get(key, "").rstrip("/") for key in ("CI_JOB_URL", "CI_PROJECT_URL",
563
+ "CI_JOB_ID"))
564
+ if not job:
565
+ return artifact_url, None
566
+ browse = f"{job}/artifacts/browse/{CI_DIR}/"
567
+
568
+ def file_url(path):
569
+ quoted = urllib.parse.quote(f"{CI_DIR}/{path}")
570
+ return f"{project}/-/jobs/{job_id}/artifacts/file/{quoted}" if project and job_id else browse
571
+ return artifact_url or browse, file_url
572
+ url = artifact_url or environ.get("MOBSTER_ARTIFACT_URL", "").strip()
573
+ return url or _github_run_url(environ), None
574
+
575
+
576
+ def _github_run_url(environ):
577
+ if not environ.get("GITHUB_RUN_ID"):
578
+ return None
579
+ server = environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/")
580
+ return f"{server}/{environ.get('GITHUB_REPOSITORY', '')}/actions/runs/{environ['GITHUB_RUN_ID']}"
581
+
582
+
583
+ def failed_comment(doc, environ=None):
584
+ environ = os.environ if environ is None else environ
585
+ if host_of(environ) == "gitlab" and environ.get("CI_JOB_URL"):
586
+ log = environ["CI_JOB_URL"]
587
+ else:
588
+ log = _github_run_url(environ)
589
+ where = f"The [job's log]({log}) says why" if log else "The job's log says why"
590
+ return f"{MARKER}\n### Mobster checks couldn't run\n\n{where} (exit code {doc.get('exitCode', '?')}).\n"
591
+
592
+
593
+ def comment_markdown(root, environ=None, artifact_url=None):
594
+ """The comment for the report in .mobster/ci: pr_report's, with links into this job's artifacts."""
595
+ from ..testkit import pr_report
596
+ environ = os.environ if environ is None else environ
597
+ folder = Path(root) / CI_DIR
598
+ try:
599
+ doc = json.loads((folder / "ci.json").read_text(encoding="utf-8"))
600
+ except (OSError, ValueError):
601
+ raise DevError(f"There's no report in {CI_DIR}, so there's nothing to comment.",
602
+ "Run `mobster ci run` first.") from None
603
+ try:
604
+ pr = json.loads((folder / "pr.json").read_text(encoding="utf-8"))
605
+ except (OSError, ValueError):
606
+ pr = None
607
+ if not isinstance(pr, dict):
608
+ return failed_comment(doc, environ)
609
+ hosted = pr.get("hostedReport")
610
+ if isinstance(hosted, str):
611
+ try:
612
+ return pr_report.comment(pr, artifact_url=hosted, file_url=pr_report.hosted_pages(hosted))
613
+ except ValueError:
614
+ pass # not a report link: the job's artifacts, as without --share
615
+ report, file_url = links(host_of(environ), environ, artifact_url)
616
+ return pr_report.comment(pr, artifact_url=report, file_url=file_url)
617
+
618
+
619
+ def _open(request, opener=None, timeout=30):
620
+ with (opener or urllib.request.urlopen)(request, timeout=timeout) as response:
621
+ text = response.read().decode("utf-8")
622
+ return json.loads(text) if text else None
623
+
624
+
625
+ class GitHub:
626
+ """The one sticky PR comment, through the REST API with the job's token."""
627
+
628
+ def __init__(self, token, repository, api="https://api.github.com", opener=None):
629
+ self.token, self.repository, self.api, self.opener = token, repository, api.rstrip("/"), opener
630
+
631
+ def call(self, method, path, body=None):
632
+ data = json.dumps(body).encode() if body is not None else None
633
+ headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json",
634
+ "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "mobster-ci"}
635
+ if data is not None:
636
+ headers["Content-Type"] = "application/json"
637
+ return _open(urllib.request.Request(f"{self.api}{path}", data=data, method=method, headers=headers),
638
+ self.opener)
639
+
640
+ def sticky(self, number, body):
641
+ base = f"/repos/{self.repository}/issues"
642
+ for page in range(1, MAX_PAGES + 1):
643
+ items = self.call("GET", f"{base}/{number}/comments?per_page={PAGE}&page={page}") or []
644
+ for item in items:
645
+ if MARKER in (item.get("body") or ""):
646
+ done = self.call("PATCH", f"{base}/comments/{item['id']}", {"body": body})
647
+ return "updated", (done or {}).get("html_url") or item.get("html_url")
648
+ if len(items) < PAGE:
649
+ break
650
+ done = self.call("POST", f"{base}/{number}/comments", {"body": body})
651
+ return "created", (done or {}).get("html_url")
652
+
653
+
654
+ class GitLab:
655
+ """The one sticky MR note, through the REST API with a project access token (api scope)."""
656
+
657
+ def __init__(self, token, project, api, opener=None):
658
+ self.token, self.project, self.api, self.opener = token, str(project), api.rstrip("/"), opener
659
+
660
+ def call(self, method, path, body=None):
661
+ data = json.dumps(body).encode() if body is not None else None
662
+ headers = {"PRIVATE-TOKEN": self.token, "User-Agent": "mobster-ci"}
663
+ if data is not None:
664
+ headers["Content-Type"] = "application/json"
665
+ return _open(urllib.request.Request(f"{self.api}{path}", data=data, method=method, headers=headers),
666
+ self.opener)
667
+
668
+ def sticky(self, iid, body):
669
+ base = f"/projects/{urllib.parse.quote(self.project, safe='')}/merge_requests/{iid}/notes"
670
+ for page in range(1, MAX_PAGES + 1):
671
+ items = self.call("GET", f"{base}?per_page={PAGE}&page={page}&sort=asc") or []
672
+ for item in items:
673
+ if not item.get("system") and MARKER in (item.get("body") or ""):
674
+ self.call("PUT", f"{base}/{item['id']}", {"body": body})
675
+ return "updated", item["id"]
676
+ if len(items) < PAGE:
677
+ break
678
+ done = self.call("POST", base, {"body": body})
679
+ return "created", (done or {}).get("id")
680
+
681
+
682
+ def github_pr_number(environ):
683
+ try:
684
+ with open(environ.get("GITHUB_EVENT_PATH", ""), encoding="utf-8") as handle:
685
+ event = json.load(handle)
686
+ except (OSError, ValueError):
687
+ return None
688
+ number = (event.get("pull_request") or {}).get("number")
689
+ if number is None and (event.get("issue") or {}).get("pull_request"):
690
+ number = event["issue"].get("number")
691
+ return number if isinstance(number, int) else None
692
+
693
+
694
+ def post(body, environ=None, opener=None):
695
+ environ = os.environ if environ is None else environ
696
+ host = host_of(environ)
697
+ if host == "github":
698
+ number = github_pr_number(environ)
699
+ if number is None:
700
+ return "Not a pull request, so there's no comment; the job's summary has the verdict."
701
+ token = environ.get("GITHUB_TOKEN", "").strip()
702
+ if not token:
703
+ return "No GITHUB_TOKEN, so Mobster didn't comment. Give the step `GITHUB_TOKEN: ${{ github.token }}`."
704
+ client = GitHub(token, environ.get("GITHUB_REPOSITORY", ""),
705
+ environ.get("GITHUB_API_URL", "https://api.github.com"), opener)
706
+ where = number
707
+ elif host == "gitlab":
708
+ iid = environ.get("CI_MERGE_REQUEST_IID", "").strip()
709
+ if not iid:
710
+ return "Not a merge request pipeline, so there's no comment."
711
+ token = environ.get("MOBSTER_GITLAB_TOKEN", "").strip()
712
+ if not token:
713
+ return ("No MOBSTER_GITLAB_TOKEN, so Mobster didn't comment. Add a project access token with the api "
714
+ "scope as a masked CI/CD variable named MOBSTER_GITLAB_TOKEN.")
715
+ client = GitLab(token, environ.get("CI_PROJECT_ID", ""), environ.get("CI_API_V4_URL", ""), opener)
716
+ where = iid
717
+ else:
718
+ return "Not in a CI job, so Mobster posted nothing."
719
+ # a comment never fails the job: every answer from the host becomes one line
720
+ try:
721
+ how, _ = client.sticky(where, body)
722
+ except urllib.error.HTTPError as problem:
723
+ if problem.code in (401, 403, 404):
724
+ return (f"Mobster couldn't comment: {'GitLab' if host == 'gitlab' else 'GitHub'} answered "
725
+ f"{problem.code}. The token can't write to this {'merge request' if host == 'gitlab' else 'PR'}"
726
+ " (a PR from a fork gets a read-only token).")
727
+ return f"Mobster couldn't comment: the host answered {problem.code}."
728
+ except (urllib.error.URLError, OSError, ValueError) as problem:
729
+ return f"Mobster couldn't reach the host to comment ({type(problem).__name__})."
730
+ return f"{how.capitalize()} the {'MR' if host == 'gitlab' else 'PR'} comment."
731
+
732
+
733
+ def job_summary(body, environ=None):
734
+ path = ((os.environ if environ is None else environ).get("GITHUB_STEP_SUMMARY") or "").strip()
735
+ if path:
736
+ with open(path, "a", encoding="utf-8") as handle:
737
+ handle.write(body.replace(MARKER + "\n", "") + "\n")