@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.
- package/README.md +9 -11
- package/bin/mobiflow.js +94 -61
- package/package.json +8 -3
- package/pyproject.toml +59 -0
- package/src/mobiflow/__init__.py +9 -0
- package/src/mobiflow/__main__.py +6 -0
- package/src/mobiflow/baseline.py +228 -0
- package/src/mobiflow/casedata.py +159 -0
- package/src/mobiflow/cases/__init__.py +715 -0
- package/src/mobiflow/cli.py +1423 -0
- package/src/mobiflow/cloud/__init__.py +28 -0
- package/src/mobiflow/cloud/base.py +272 -0
- package/src/mobiflow/cloud/browserstack.py +330 -0
- package/src/mobiflow/cloud/maestro_cloud.py +141 -0
- package/src/mobiflow/cloud/media.py +269 -0
- package/src/mobiflow/cloud/runner.py +156 -0
- package/src/mobiflow/cloud/testmu.py +378 -0
- package/src/mobiflow/config/__init__.py +538 -0
- package/src/mobiflow/deps.py +377 -0
- package/src/mobiflow/devices.py +717 -0
- package/src/mobiflow/explore.py +623 -0
- package/src/mobiflow/incremental.py +198 -0
- package/src/mobiflow/init/__init__.py +794 -0
- package/src/mobiflow/llm.py +462 -0
- package/src/mobiflow/llm_catalog.py +232 -0
- package/src/mobiflow/maestro/__init__.py +1506 -0
- package/src/mobiflow/maestro/lifecycle.py +279 -0
- package/src/mobiflow/pipeline.py +600 -0
- package/src/mobiflow/report/__init__.py +617 -0
- package/src/mobiflow/report/static/favicon.jpg +0 -0
- package/src/mobiflow/report/static/favicon.svg +1 -0
- package/src/mobiflow/report/static/icons.svg +24 -0
- package/src/mobiflow/report/static/index.html +99 -0
- package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
- package/src/mobiflow/reporting.py +682 -0
- package/src/mobiflow/sample_apps.py +259 -0
- package/src/mobiflow/secrets.py +90 -0
- package/src/mobiflow/selectors.py +128 -0
- 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
|
+
))
|