@qubiqlabs/mobiflow 0.9.0 → 1.0.0

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 (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +94 -61
  3. package/package.json +8 -3
  4. package/pyproject.toml +59 -0
  5. package/src/mobiflow/__init__.py +9 -0
  6. package/src/mobiflow/__main__.py +6 -0
  7. package/src/mobiflow/baseline.py +228 -0
  8. package/src/mobiflow/casedata.py +159 -0
  9. package/src/mobiflow/cases/__init__.py +715 -0
  10. package/src/mobiflow/cli.py +1423 -0
  11. package/src/mobiflow/cloud/__init__.py +28 -0
  12. package/src/mobiflow/cloud/base.py +272 -0
  13. package/src/mobiflow/cloud/browserstack.py +330 -0
  14. package/src/mobiflow/cloud/maestro_cloud.py +141 -0
  15. package/src/mobiflow/cloud/media.py +269 -0
  16. package/src/mobiflow/cloud/runner.py +156 -0
  17. package/src/mobiflow/cloud/testmu.py +378 -0
  18. package/src/mobiflow/config/__init__.py +538 -0
  19. package/src/mobiflow/deps.py +377 -0
  20. package/src/mobiflow/devices.py +717 -0
  21. package/src/mobiflow/explore.py +623 -0
  22. package/src/mobiflow/incremental.py +198 -0
  23. package/src/mobiflow/init/__init__.py +794 -0
  24. package/src/mobiflow/llm.py +462 -0
  25. package/src/mobiflow/llm_catalog.py +232 -0
  26. package/src/mobiflow/maestro/__init__.py +1506 -0
  27. package/src/mobiflow/maestro/lifecycle.py +279 -0
  28. package/src/mobiflow/pipeline.py +600 -0
  29. package/src/mobiflow/report/__init__.py +617 -0
  30. package/src/mobiflow/report/static/favicon.jpg +0 -0
  31. package/src/mobiflow/report/static/favicon.svg +1 -0
  32. package/src/mobiflow/report/static/icons.svg +24 -0
  33. package/src/mobiflow/report/static/index.html +99 -0
  34. package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
  35. package/src/mobiflow/reporting.py +682 -0
  36. package/src/mobiflow/sample_apps.py +259 -0
  37. package/src/mobiflow/secrets.py +90 -0
  38. package/src/mobiflow/selectors.py +128 -0
  39. package/src/mobiflow/suite.py +263 -0
@@ -0,0 +1,794 @@
1
+ """Terminal-only interactive setup wizard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import questionary
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.rule import Rule
14
+
15
+ from mobiflow.cases import EXAMPLE_CASE
16
+ from mobiflow.config import (
17
+ CONFIG_FILENAME,
18
+ DeviceConfig,
19
+ LlmConfig,
20
+ MobiflowConfig,
21
+ ProjectConfig,
22
+ ProjectMode,
23
+ RunConfig,
24
+ StackConfig,
25
+ save_config,
26
+ )
27
+
28
+ console = Console()
29
+
30
+ _BANNER = r"""
31
+ __ __ _ _ _____ _
32
+ | \/ | ___ | |__ (_) ___| | _____ __
33
+ | |\/| |/ _ \| '_ \| | |_ | |/ _ \ \ /\ / /
34
+ | | | | (_) | |_) | | _| | | (_) \ V V /
35
+ |_| |_|\___/|_.__/|_|_| |_|\___/ \_/\_/
36
+ """
37
+
38
+ _CASES_README = """# mobiflow test cases
39
+
40
+ Put **one plain-text file per scenario** in this folder (`.txt`).
41
+
42
+ ## Intent style
43
+
44
+ ```text
45
+ appId: org.wikipedia
46
+ platform: android
47
+ task: Open Wikipedia, dismiss onboarding, confirm Search is visible
48
+ ```
49
+
50
+ ## Guided steps
51
+
52
+ ```text
53
+ @smoke
54
+ appId: com.android.settings
55
+ platform: android
56
+
57
+ 1. Launch Settings
58
+ 2. Confirm Wi-Fi or Network is visible
59
+ ```
60
+
61
+ ## Commands
62
+
63
+ ```bash
64
+ mobiflow run cases/example.txt
65
+ mobiflow run cases/example.txt --gen-only
66
+ mobiflow devices
67
+ mobiflow status
68
+ ```
69
+ """
70
+
71
+
72
+ def _project_guide(*, repo: Path, llm: LlmConfig, stack: StackConfig, device: DeviceConfig) -> str:
73
+ azure_extra = ""
74
+ if (llm.provider or "").lower().startswith("azure") or (
75
+ llm.discovery and "azure" in (llm.discovery or "")
76
+ ):
77
+ azure_extra = (
78
+ f"\nexport {llm.azure_endpoint_env}=https://YOUR.openai.azure.com\n"
79
+ )
80
+ return f"""# MobiFlow — project guide
81
+
82
+ Generated by `mobiflow init`.
83
+
84
+ ## What was added
85
+
86
+ | Path | Purpose |
87
+ |------|---------|
88
+ | `mobiflow.config.yaml` | Picks discovery/codegen models + device/run |
89
+ | `llm.json` | Catalog of Azure / OpenAI / Anthropic / Google models |
90
+ | `cases/` | Plain-text scenarios (one `.txt` each) |
91
+ | `flows/` | Generated Maestro YAML |
92
+ | `.mobiflow/` | Run artifacts (created on first run) |
93
+ | `MOBIFLOW.md` | This guide |
94
+
95
+ ## 1. Models (`llm.json`) + selection
96
+
97
+ ```yaml
98
+ llm:
99
+ catalog: llm.json
100
+ discovery: azure-gpt4o # adaptive explore / plan
101
+ codegen: anthropic-sonnet # Maestro YAML authoring
102
+ ```
103
+
104
+ ```bash
105
+ export {llm.api_key_env}=your-key{azure_extra}
106
+ mobiflow llm list
107
+ ```
108
+
109
+ Discovery: `{llm.discovery or 'default'}` · Codegen: `{llm.codegen or llm.discovery or 'default'}`
110
+ Stack: **{stack.tool}** / **{stack.language}** / `{stack.flow_dir}` · Platform: **{device.platform}** · appId: `{device.app_id or '(per case)'}`
111
+
112
+ JavaScript: Maestro GraalJS via `${{…}}`, `evalScript`, and `runScript: scripts/*.js` when `stack.language` is `yaml+js`.
113
+
114
+ ## 2. Add a case
115
+
116
+ ```bash
117
+ cp cases/example.txt cases/my-flow.txt
118
+ # edit appId / platform / task
119
+ mobiflow run cases/my-flow.txt
120
+ ```
121
+
122
+ ## 3. Commands
123
+
124
+ ```bash
125
+ cd {repo}
126
+
127
+ mobiflow status # Maestro CLI + devices
128
+ mobiflow devices # adb + booted simulators
129
+ mobiflow setup # install missing Maestro / JDK / pip packages
130
+ mobiflow run cases/example.txt
131
+ mobiflow run cases/example.txt --gen-only # YAML only
132
+ mobiflow run cases/example.txt --no-heal
133
+ mobiflow gen "Open Settings and assert visible" --platform android
134
+ mobiflow config show
135
+ mobiflow llm list
136
+ ```
137
+
138
+ ## 4. Prerequisites
139
+
140
+ `mobiflow init` (step 4) and `mobiflow setup` can auto-install Maestro, JDK (via Homebrew), and missing pip packages. You still need:
141
+
142
+ - Python 3.11+
143
+ - Android emulator and/or iOS Simulator for live runs
144
+ - LLM API key for the profile selected in `llm.json`
145
+
146
+ Secrets never go in `mobiflow.config.yaml` — only env var **names**.
147
+ """
148
+
149
+
150
+ def _fail(msg: str) -> None:
151
+ console.print(f"[red]{msg}[/red]")
152
+ raise SystemExit(1)
153
+
154
+
155
+ def _ok(msg: str) -> None:
156
+ console.print(f" [green]✓[/green] {msg}")
157
+
158
+
159
+ def _warn(msg: str) -> None:
160
+ console.print(f" [yellow]![/yellow] {msg}")
161
+
162
+
163
+ _WIZARD_STEPS = 5
164
+
165
+
166
+ def _step(n: int, title: str) -> None:
167
+ console.print()
168
+ console.print(Rule(f"[bold cyan]Step {n}/{_WIZARD_STEPS}[/bold cyan] — {title}"))
169
+
170
+
171
+ def _require(val, *, what: str):
172
+ if val is None:
173
+ _fail(f"Cancelled while asking for {what}.")
174
+ return val
175
+
176
+
177
+ def _ask_line(prompt: str, *, what: str, default: str = "", required: bool = True) -> str:
178
+ kwargs = {}
179
+ if default:
180
+ kwargs["default"] = default
181
+ ans = questionary.text(prompt, **kwargs).ask()
182
+ if ans is None:
183
+ _fail(f"Cancelled while asking for {what}.")
184
+ ans = str(ans).strip()
185
+ if required and not ans:
186
+ _fail(f"{what} cannot be empty.")
187
+ return ans
188
+
189
+
190
+ def run_init(
191
+ *,
192
+ mode: Optional[str] = None,
193
+ path: Optional[str] = None,
194
+ yes: bool = False,
195
+ install_deps: Optional[bool] = None,
196
+ ) -> Path:
197
+ if yes:
198
+ # Default: auto-install missing packages in non-interactive mode
199
+ do_install = True if install_deps is None else install_deps
200
+ return _init_noninteractive(mode=mode, path=path, install_deps=do_install)
201
+
202
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
203
+ _fail(
204
+ "Interactive `mobiflow init` needs a terminal.\n"
205
+ " Use: mobiflow init --mode local --path . --yes"
206
+ )
207
+
208
+ try:
209
+ return _run_init_interactive(
210
+ mode=mode, path=path, install_deps=install_deps
211
+ )
212
+ except KeyboardInterrupt:
213
+ console.print("\n[yellow]Interrupted.[/yellow]")
214
+ raise SystemExit(130) from None
215
+
216
+
217
+ def _init_noninteractive(
218
+ *, mode: Optional[str], path: Optional[str], install_deps: bool = True
219
+ ) -> Path:
220
+ from mobiflow.llm_catalog import default_catalog_seed, save_catalog
221
+
222
+ mode_choice = (mode or "local").lower()
223
+ if mode_choice == "clone":
224
+ mode_choice = "local"
225
+ repo = Path(path or ".").expanduser().resolve()
226
+ repo.mkdir(parents=True, exist_ok=True)
227
+
228
+ catalog = default_catalog_seed()
229
+ save_catalog(catalog, repo)
230
+ names = catalog.names()
231
+ default = names[0]
232
+ for n in names:
233
+ if catalog.models[n].provider.lower().startswith("azure"):
234
+ default = n
235
+ break
236
+
237
+ llm = LlmConfig(
238
+ catalog="llm.json",
239
+ discovery=default,
240
+ codegen=default,
241
+ provider=catalog.models[default].provider,
242
+ model=catalog.models[default].model,
243
+ api_key_env=catalog.models[default].api_key_env,
244
+ endpoint=catalog.models[default].endpoint,
245
+ )
246
+ stack = StackConfig(language="yaml+js", scripts_dir="flows/scripts")
247
+ device = DeviceConfig(platform="android", app_id="")
248
+ config = MobiflowConfig(
249
+ project=ProjectConfig(mode=ProjectMode(mode_choice if mode_choice != "clone" else "local"), path=str(repo)),
250
+ llm=llm,
251
+ stack=stack,
252
+ device=device,
253
+ run=RunConfig(),
254
+ )
255
+ out = save_config(config, repo)
256
+ _ensure_project_docs(repo, config)
257
+ console.print(f"[green]Wrote[/green] {out}")
258
+ console.print(f"[green]Wrote[/green] {repo / 'llm.json'}")
259
+
260
+ if install_deps:
261
+ _run_deps_setup(
262
+ want_anthropic=catalog.models[default].provider.lower()
263
+ in ("anthropic", "claude"),
264
+ install_adb=False,
265
+ auto=True,
266
+ )
267
+ else:
268
+ console.print("[dim]Skipped dependency install (--no-install-deps)[/dim]")
269
+
270
+ console.print(f"[dim]Export your API key, then: mobiflow run cases/example.txt[/dim]")
271
+ return out
272
+
273
+
274
+ def _run_init_interactive(
275
+ *,
276
+ mode: Optional[str],
277
+ path: Optional[str],
278
+ install_deps: Optional[bool],
279
+ ) -> Path:
280
+ console.print(_BANNER, style="bold cyan")
281
+ console.print(
282
+ Panel(
283
+ "[bold]Setup wizard[/bold]\n"
284
+ "[dim]Guided[/dim] "
285
+ "[cyan]1[/cyan] project → [cyan]2[/cyan] LLM → "
286
+ "[cyan]3[/cyan] device → [cyan]4[/cyan] deps → [cyan]5[/cyan] finish",
287
+ border_style="dim",
288
+ padding=(0, 2),
289
+ )
290
+ )
291
+
292
+ # Step 1 — project
293
+ _step(1, "Project path")
294
+ mode_choice = mode
295
+ if mode_choice is None:
296
+ mode_choice = _require(
297
+ questionary.select(
298
+ "How do you want to start?",
299
+ choices=[
300
+ questionary.Choice("Use current / local path", value="local"),
301
+ questionary.Choice("Connect an existing folder", value="existing"),
302
+ questionary.Choice("Start a fresh scaffold", value="new"),
303
+ ],
304
+ default="local",
305
+ ).ask(),
306
+ what="project mode",
307
+ )
308
+ mode_choice = str(mode_choice).lower()
309
+ if path:
310
+ project_path = path
311
+ else:
312
+ project_path = _ask_line(
313
+ "Project path",
314
+ what="project path",
315
+ default=str(Path.cwd()),
316
+ required=True,
317
+ )
318
+ repo = Path(str(project_path)).expanduser().resolve()
319
+ repo.mkdir(parents=True, exist_ok=True)
320
+ _ok(f"Using project at {repo}")
321
+
322
+ existing = repo / CONFIG_FILENAME
323
+ if existing.exists():
324
+ overwrite = questionary.confirm(
325
+ f"{CONFIG_FILENAME} already exists. Overwrite?",
326
+ default=False,
327
+ ).ask()
328
+ if overwrite is None:
329
+ _fail("Cancelled while confirming overwrite.")
330
+ if not overwrite:
331
+ console.print("[yellow]Aborted — existing config left unchanged.[/yellow]")
332
+ return existing
333
+
334
+ # Step 2 — LLM
335
+ _step(2, "LLM catalog & model selection")
336
+ llm = _ask_llm(repo)
337
+
338
+ # Step 3 — device / stack
339
+ _step(3, "Device defaults & Maestro stack")
340
+ provider = _require(
341
+ questionary.select(
342
+ "Device lab:",
343
+ choices=[
344
+ questionary.Choice("Local (adb / Android AVD / iOS Simulator)", value="local"),
345
+ questionary.Choice("Maestro Cloud (official `maestro cloud`)", value="maestro"),
346
+ questionary.Choice("BrowserStack App Automate (Maestro)", value="browserstack"),
347
+ questionary.Choice("TestMu AI / HyperExecute (Maestro)", value="testmu"),
348
+ ],
349
+ default="local",
350
+ ).ask(),
351
+ what="provider",
352
+ )
353
+ platform = _require(
354
+ questionary.select(
355
+ "Default platform:",
356
+ choices=["android", "ios"],
357
+ default="android",
358
+ ).ask(),
359
+ what="platform",
360
+ )
361
+ app_id = _ask_line(
362
+ "Default appId (Enter to leave empty — set per case)",
363
+ what="appId",
364
+ default="",
365
+ required=False,
366
+ )
367
+ device_kwargs: dict = {"provider": str(provider), "platform": platform, "app_id": app_id}
368
+ if provider != "local":
369
+ if provider == "maestro":
370
+ default_device = "pixel_7" if platform == "android" else "iPhone-16"
371
+ device_prompt = "Maestro Cloud device model (e.g. pixel_7, iPhone-16)"
372
+ app_prompt = (
373
+ "Path to .apk / .ipa to upload "
374
+ "(or leave empty if using an existing Maestro app binary id later)"
375
+ )
376
+ elif provider == "browserstack":
377
+ default_device = (
378
+ "Google Pixel 7-13.0" if platform == "android" else "iPhone 15-17.0"
379
+ )
380
+ device_prompt = "Cloud device name (comma-separated for multiple)"
381
+ app_prompt = (
382
+ "Path to .apk / .ipa to upload (or leave empty if using app_url later)"
383
+ )
384
+ else:
385
+ default_device = "Pixel 6-14" if platform == "android" else "iPhone 15"
386
+ device_prompt = "Cloud device name (comma-separated for multiple)"
387
+ app_prompt = (
388
+ "Path to .apk / .ipa to upload (or leave empty if using app_url later)"
389
+ )
390
+ cloud_device = _ask_line(
391
+ device_prompt,
392
+ what="device_id",
393
+ default=default_device,
394
+ required=True,
395
+ )
396
+ app_path = _ask_line(
397
+ app_prompt,
398
+ what="app_path",
399
+ default="",
400
+ required=False,
401
+ )
402
+ device_kwargs["device_id"] = cloud_device
403
+ device_kwargs["app_path"] = app_path
404
+ device_kwargs["auto_start"] = False
405
+ if provider == "maestro":
406
+ console.print(
407
+ "[dim]Credentials: export MAESTRO_CLOUD_API_KEY=… "
408
+ "(or MAESTRO_API_KEY) before `mobiflow run`.[/dim]"
409
+ )
410
+ if provider == "testmu":
411
+ real = questionary.confirm(
412
+ "Use real devices on TestMu? (No = virtual emulator/simulator)",
413
+ default=True,
414
+ ).ask()
415
+ device_kwargs["real_mobile"] = bool(real)
416
+ device = DeviceConfig(**device_kwargs)
417
+ language = _require(
418
+ questionary.select(
419
+ "Maestro authoring language:",
420
+ choices=[
421
+ questionary.Choice(
422
+ "YAML + JavaScript (evalScript / runScript) — recommended",
423
+ value="yaml+js",
424
+ ),
425
+ questionary.Choice("YAML only", value="yaml"),
426
+ ],
427
+ default="yaml+js",
428
+ ).ask(),
429
+ what="language",
430
+ )
431
+ stack = StackConfig(
432
+ tool="maestro",
433
+ language=str(language),
434
+ runner="maestro",
435
+ scripts_dir="flows/scripts",
436
+ )
437
+ _ok(
438
+ f"Stack: maestro / {language} · provider={provider} · "
439
+ f"platform={platform} · appId={app_id or '(per case)'}"
440
+ )
441
+
442
+ # Step 4 — missing packages / tools
443
+ _step(4, "Install missing packages & tools")
444
+ want_anthropic = (llm.provider or "").lower() in ("anthropic", "claude")
445
+ if not want_anthropic:
446
+ # Also check selected catalog profile ids
447
+ for name in (llm.discovery, llm.codegen):
448
+ if name and "anthropic" in name.lower():
449
+ want_anthropic = True
450
+ break
451
+
452
+ from mobiflow.deps import probe_dependencies
453
+
454
+ probed = probe_dependencies(want_anthropic=want_anthropic)
455
+ missing = [i for i in probed if not i.ok]
456
+ installable = [i for i in missing if i.installable]
457
+ for item in probed:
458
+ mark = "[green]OK[/green]" if item.ok else "[yellow]MISSING[/yellow]"
459
+ console.print(f" {mark} {item.label}")
460
+ if not item.ok and item.detail:
461
+ console.print(f" [dim]{item.detail}[/dim]")
462
+
463
+ do_install = install_deps
464
+ install_adb = False
465
+ if do_install is None:
466
+ if installable:
467
+ answer = questionary.confirm(
468
+ "Automatically install missing packages/tools now?",
469
+ default=True,
470
+ ).ask()
471
+ if answer is None:
472
+ _fail("Cancelled while asking about dependency install.")
473
+ do_install = bool(answer)
474
+ else:
475
+ do_install = False
476
+ if not missing:
477
+ _ok("All checked dependencies are available")
478
+ else:
479
+ _warn("Some optional tools are missing (not auto-installable here)")
480
+
481
+ if do_install and installable:
482
+ adb_missing = any(i.id == "adb" and not i.ok and i.installable for i in probed)
483
+ if adb_missing and platform_wants_android(platform):
484
+ adb_ans = questionary.confirm(
485
+ "Also install Android platform-tools (adb) via Homebrew?",
486
+ default=True,
487
+ ).ask()
488
+ install_adb = bool(adb_ans)
489
+ _run_deps_setup(
490
+ want_anthropic=want_anthropic,
491
+ install_adb=install_adb,
492
+ auto=True,
493
+ )
494
+ elif do_install and not installable:
495
+ _warn("Nothing installable automatically — see notes above")
496
+ else:
497
+ _warn("Skipped dependency install")
498
+
499
+ # Step 5 — write
500
+ _step(5, "Write config & finish")
501
+ try:
502
+ pm = ProjectMode.NEW if mode_choice == "new" else (
503
+ ProjectMode.EXISTING if mode_choice == "existing" else ProjectMode.LOCAL
504
+ )
505
+ config = MobiflowConfig(
506
+ project=ProjectConfig(mode=pm, path=str(repo)),
507
+ llm=llm,
508
+ stack=stack,
509
+ device=device,
510
+ run=RunConfig(heal=2, adaptive=True),
511
+ )
512
+ out = save_config(config, repo)
513
+ _ensure_project_docs(repo, config)
514
+ except Exception as e: # noqa: BLE001
515
+ _fail(f"Failed to write config: {e}")
516
+
517
+ _ok(f"Wrote {out}")
518
+ _ok(f"LLM catalog → {repo / 'llm.json'}")
519
+ _ok(f"Docs → {repo / 'MOBIFLOW.md'}")
520
+ _ok(f"Example case → {repo / 'cases' / 'example.txt'}")
521
+ _print_usage(repo, llm)
522
+ return out
523
+
524
+
525
+ def platform_wants_android(platform: str) -> bool:
526
+ return (platform or "").lower() == "android"
527
+
528
+
529
+ def _run_deps_setup(
530
+ *,
531
+ want_anthropic: bool,
532
+ install_adb: bool,
533
+ auto: bool,
534
+ ) -> None:
535
+ from mobiflow.deps import install_missing, probe_dependencies
536
+
537
+ def _log(msg: str) -> None:
538
+ console.print(f"[dim]{msg}[/dim]")
539
+
540
+ if not auto:
541
+ return
542
+
543
+ report = install_missing(
544
+ want_anthropic=want_anthropic,
545
+ install_adb=install_adb,
546
+ log=_log,
547
+ )
548
+ for action in report.actions:
549
+ _ok(action)
550
+ for err in report.errors:
551
+ _warn(err)
552
+
553
+ # Final status
554
+ still = [i for i in report.items if not i.ok and i.required]
555
+ if still:
556
+ _warn("Still missing (manual install needed):")
557
+ for i in still:
558
+ console.print(f" · {i.label}: {i.detail}")
559
+ else:
560
+ _ok("Required dependencies look good")
561
+ # Refresh optional display
562
+ optional_missing = [i for i in probe_dependencies(want_anthropic=want_anthropic) if not i.ok and not i.required]
563
+ for i in optional_missing:
564
+ console.print(f" [dim]optional missing:[/dim] {i.label}")
565
+
566
+
567
+ def _ask_llm(repo: Path) -> LlmConfig:
568
+ from mobiflow.llm_catalog import (
569
+ ModelEntry,
570
+ default_catalog_seed,
571
+ load_catalog,
572
+ save_catalog,
573
+ )
574
+
575
+ catalog_file = repo / "llm.json"
576
+ if catalog_file.exists():
577
+ catalog = load_catalog(repo)
578
+ console.print(f" Found existing [cyan]llm.json[/cyan] ({len(catalog.models)} models)")
579
+ else:
580
+ catalog = default_catalog_seed()
581
+ save_catalog(catalog, repo)
582
+ _ok(f"Wrote starter catalog → {catalog_file}")
583
+ console.print(
584
+ " [dim]Edit llm.json anytime to add Azure / OpenAI / Anthropic / Google models.[/dim]"
585
+ )
586
+
587
+ add_more = questionary.confirm(
588
+ "Add or update a model profile in llm.json now?",
589
+ default=not catalog_file.exists() or len(catalog.models) < 2,
590
+ ).ask()
591
+ if add_more is None:
592
+ _fail("Cancelled while asking about llm.json.")
593
+ while add_more:
594
+ entry_id, entry = _ask_model_entry()
595
+ catalog.models[entry_id] = entry
596
+ save_catalog(catalog, repo)
597
+ _ok(f"Saved profile [cyan]{entry_id}[/cyan] → {entry.display_name}")
598
+ add_more = bool(
599
+ questionary.confirm("Add another model profile?", default=False).ask()
600
+ )
601
+
602
+ names = catalog.names()
603
+ if not names:
604
+ _fail("llm.json has no models. Add at least one profile.")
605
+
606
+ choices = [
607
+ questionary.Choice(
608
+ title=f"{n} — {catalog.models[n].display_name} ({catalog.models[n].provider})",
609
+ value=n,
610
+ )
611
+ for n in names
612
+ ]
613
+ default = names[0]
614
+ for n in names:
615
+ if catalog.models[n].provider.lower().startswith("azure"):
616
+ default = n
617
+ break
618
+
619
+ discovery = _require(
620
+ questionary.select(
621
+ "Discovery model (adaptive explore / plan on device):",
622
+ choices=choices,
623
+ default=default,
624
+ ).ask(),
625
+ what="discovery model",
626
+ )
627
+ codegen = _require(
628
+ questionary.select(
629
+ "Codegen model (Maestro YAML authoring):",
630
+ choices=choices,
631
+ default=discovery,
632
+ ).ask(),
633
+ what="codegen model",
634
+ )
635
+
636
+ for role, name in (("discovery", discovery), ("codegen", codegen)):
637
+ entry = catalog.models[name]
638
+ if os.environ.get(entry.api_key_env):
639
+ _ok(f"{role}: {entry.api_key_env} is set")
640
+ else:
641
+ _warn(f"{role}: export {entry.api_key_env}=... before `mobiflow run`")
642
+
643
+ return LlmConfig(
644
+ catalog="llm.json",
645
+ discovery=discovery,
646
+ codegen=codegen,
647
+ provider=catalog.models[discovery].provider,
648
+ model=catalog.models[discovery].model,
649
+ api_key_env=catalog.models[discovery].api_key_env,
650
+ endpoint=catalog.models[discovery].endpoint,
651
+ )
652
+
653
+
654
+ def _ask_model_entry() -> tuple[str, "ModelEntry"]:
655
+ from mobiflow.llm_catalog import ModelEntry
656
+
657
+ provider = _require(
658
+ questionary.select(
659
+ "Provider:",
660
+ choices=["azure", "openai", "anthropic", "google"],
661
+ default="azure",
662
+ ).ask(),
663
+ what="provider",
664
+ )
665
+ entry_id = _require(
666
+ questionary.text(
667
+ "Profile id (used in mobiflow.config.yaml):",
668
+ default=f"{provider}-main",
669
+ ).ask(),
670
+ what="profile id",
671
+ )
672
+ entry_id = str(entry_id).strip().replace(" ", "-")
673
+ if not entry_id:
674
+ _fail("Profile id cannot be empty.")
675
+
676
+ defaults = {
677
+ "azure": ("gpt-4o", "AZURE_OPENAI_API_KEY"),
678
+ "openai": ("gpt-4o", "OPENAI_API_KEY"),
679
+ "anthropic": ("claude-sonnet-4-6", "ANTHROPIC_API_KEY"),
680
+ "google": ("gemini-2.0-flash", "GOOGLE_API_KEY"),
681
+ }
682
+ model_default, key_default = defaults.get(provider, ("gpt-4o", "MOBIFLOW_LLM_API_KEY"))
683
+
684
+ model = _require(
685
+ questionary.text(
686
+ "Model / base model id (e.g. gpt-4o, gpt-5.4):",
687
+ default=model_default,
688
+ ).ask(),
689
+ what="model name",
690
+ )
691
+ deployment = None
692
+ endpoint = None
693
+ if provider == "azure":
694
+ deployment = _require(
695
+ questionary.text(
696
+ "Azure deployment name:",
697
+ default=str(model).strip() or model_default,
698
+ ).ask(),
699
+ what="deployment",
700
+ )
701
+ endpoint = _ask_line(
702
+ "Azure endpoint URL (or leave empty and use AZURE_OPENAI_ENDPOINT):",
703
+ what="endpoint",
704
+ default="",
705
+ required=False,
706
+ ) or None
707
+
708
+ api_key_env = _require(
709
+ questionary.text("API key env var name:", default=key_default).ask(),
710
+ what="api_key_env",
711
+ )
712
+ label = _ask_line(
713
+ "Label (optional):",
714
+ what="label",
715
+ default=f"{provider}/{model}",
716
+ required=False,
717
+ )
718
+
719
+ return entry_id, ModelEntry(
720
+ provider=provider,
721
+ model=str(model).strip(),
722
+ deployment=str(deployment).strip() if deployment else None,
723
+ api_key_env=str(api_key_env).strip(),
724
+ endpoint=endpoint,
725
+ label=label or None,
726
+ )
727
+
728
+
729
+ def _ensure_project_docs(repo: Path, config: MobiflowConfig) -> None:
730
+ from mobiflow.maestro import DEFAULT_HELPERS_JS
731
+
732
+ cases = repo / config.stack.cases_dir
733
+ flows = repo / config.stack.flow_dir
734
+ scripts = repo / "flows" / "scripts"
735
+ cases.mkdir(parents=True, exist_ok=True)
736
+ flows.mkdir(parents=True, exist_ok=True)
737
+ scripts.mkdir(parents=True, exist_ok=True)
738
+
739
+ example = cases / "example.txt"
740
+ if not example.exists():
741
+ # Prefer Wikipedia on android by default
742
+ text = EXAMPLE_CASE
743
+ if config.device.app_id:
744
+ text = text.replace("org.wikipedia", config.device.app_id, 1)
745
+ if config.device.platform:
746
+ text = text.replace("platform: android", f"platform: {config.device.platform}", 1)
747
+ example.write_text(text, encoding="utf-8")
748
+
749
+ helpers = scripts / "helpers.js"
750
+ if config.stack.js_enabled() and not helpers.exists():
751
+ helpers.write_text(DEFAULT_HELPERS_JS, encoding="utf-8")
752
+
753
+ (cases / "README.md").write_text(_CASES_README, encoding="utf-8")
754
+ (repo / "MOBIFLOW.md").write_text(
755
+ _project_guide(
756
+ repo=repo,
757
+ llm=config.llm,
758
+ stack=config.stack,
759
+ device=config.device,
760
+ ),
761
+ encoding="utf-8",
762
+ )
763
+ (flows / ".gitkeep").write_text("", encoding="utf-8")
764
+
765
+ # Example flow that demonstrates runScript when JS is enabled
766
+ if config.stack.js_enabled():
767
+ demo = flows / "example_with_js.yaml"
768
+ if not demo.exists():
769
+ demo.write_text(
770
+ """\
771
+ appId: com.android.settings
772
+ name: Settings smoke with JS helper
773
+ ---
774
+ - runScript: scripts/helpers.js
775
+ - evalScript: ${setOutput('runId', 'demo-' + Date.now())}
776
+ - launchApp
777
+ - assertVisible: "Settings|Network|Apps|Wi-Fi"
778
+ - stopApp
779
+ """,
780
+ encoding="utf-8",
781
+ )
782
+
783
+
784
+ def _print_usage(repo: Path, llm: LlmConfig) -> None:
785
+ console.print()
786
+ console.print(Panel.fit(
787
+ f"[bold]Next steps[/bold]\n\n"
788
+ f" export {llm.api_key_env}=...\n"
789
+ f" cd {repo}\n"
790
+ f" mobiflow status\n"
791
+ f" mobiflow run cases/example.txt\n\n"
792
+ f"[dim]Guide: MOBIFLOW.md · Models: llm.json · Config: mobiflow.config.yaml[/dim]",
793
+ border_style="cyan",
794
+ ))