@11agents/cli 0.1.23 → 0.1.25

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 (71) hide show
  1. package/README.md +52 -0
  2. package/bin/11agents.js +12 -0
  3. package/mobile-runtime/README.md +19 -0
  4. package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
  5. package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
  6. package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
  7. package/mobile-runtime/configs/publish_policy.json +40 -0
  8. package/mobile-runtime/data-templates/README.md +4 -0
  9. package/mobile-runtime/data-templates/accounts.example.csv +6 -0
  10. package/mobile-runtime/data-templates/devices.example.csv +2 -0
  11. package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
  12. package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
  13. package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
  14. package/mobile-runtime/python/pyproject.toml +34 -0
  15. package/mobile-runtime/python/src/device_control/__init__.py +5 -0
  16. package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
  17. package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
  18. package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
  19. package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
  20. package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
  21. package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
  22. package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
  23. package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
  24. package/mobile-runtime/python/src/device_control/adb.py +161 -0
  25. package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
  26. package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
  27. package/mobile-runtime/python/src/device_control/cli.py +1608 -0
  28. package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
  29. package/mobile-runtime/python/src/device_control/locks.py +162 -0
  30. package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
  31. package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
  32. package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
  33. package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
  34. package/mobile-runtime/python/src/device_control/models.py +439 -0
  35. package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
  36. package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
  37. package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
  38. package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
  39. package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
  40. package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
  41. package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
  42. package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
  43. package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
  44. package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
  45. package/mobile-runtime/python/src/device_control/store.py +137 -0
  46. package/mobile-runtime/scripts/appium_smoke.py +71 -0
  47. package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
  48. package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
  49. package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
  50. package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
  51. package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
  52. package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
  53. package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
  54. package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
  55. package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
  56. package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
  57. package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
  58. package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
  59. package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
  60. package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
  61. package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
  62. package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
  63. package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
  64. package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
  65. package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
  66. package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
  67. package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
  68. package/package.json +4 -1
  69. package/scripts/mobile-postinstall.js +26 -0
  70. package/src/commands/mobile.js +695 -0
  71. package/src/commands/runtime.js +63 -28
@@ -0,0 +1,403 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from .adb import resolve_adb_bin
16
+ from .appium_client import AppiumClient
17
+
18
+
19
+ DEFAULT_APPIUM_SERVER = "http://127.0.0.1:4723"
20
+
21
+
22
+ class AppiumEnsureError(RuntimeError):
23
+ pass
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class AppiumEnsureResult:
28
+ status: str
29
+ server_url: str
30
+ appium_bin: str
31
+ android_sdk_root: str
32
+ pid: int | None
33
+ log_path: str
34
+ smoke_udids: list[str]
35
+ message: str = ""
36
+
37
+ def to_dict(self) -> dict:
38
+ return {
39
+ "status": self.status,
40
+ "server_url": self.server_url,
41
+ "appium_bin": self.appium_bin,
42
+ "android_sdk_root": self.android_sdk_root,
43
+ "pid": self.pid,
44
+ "log_path": self.log_path,
45
+ "smoke_udids": self.smoke_udids,
46
+ "message": self.message,
47
+ }
48
+
49
+
50
+ def ensure_appium_server(
51
+ server_url: str = DEFAULT_APPIUM_SERVER,
52
+ *,
53
+ udids: list[str] | None = None,
54
+ start: bool = True,
55
+ restart: bool = False,
56
+ smoke: bool = True,
57
+ project_root: str | Path | None = None,
58
+ startup_timeout_s: float = 45,
59
+ ) -> AppiumEnsureResult:
60
+ root = Path(project_root).expanduser() if project_root else find_project_root()
61
+ normalized_url = normalize_server_url(server_url)
62
+ appium_bin = resolve_appium_bin(root)
63
+ sdk_root = resolve_android_sdk_root()
64
+ log_path = _log_path(root, normalized_url)
65
+ pid: int | None = _read_managed_pid(root, normalized_url)
66
+
67
+ if restart:
68
+ stop_managed_appium(normalized_url, project_root=root, allow_unmanaged_appium=True)
69
+ pid = None
70
+
71
+ status = _get_status(normalized_url)
72
+ started = False
73
+ if status is None:
74
+ if not start:
75
+ raise AppiumEnsureError(f"Appium is not reachable at {normalized_url}")
76
+ _require_local_server(normalized_url)
77
+ pid = _start_appium(normalized_url, appium_bin, sdk_root, log_path, root)
78
+ started = True
79
+ status = _wait_for_status(normalized_url, startup_timeout_s)
80
+ if status is None:
81
+ raise AppiumEnsureError(f"Appium did not become ready within {int(startup_timeout_s)}s; log={log_path}")
82
+
83
+ smoke_udids = [item for item in (udids or []) if item]
84
+ if smoke and smoke_udids:
85
+ try:
86
+ for udid in smoke_udids:
87
+ smoke_appium_session(normalized_url, udid)
88
+ except Exception as exc:
89
+ if not started and (_is_managed_process(root, normalized_url) or _local_appium_pid(normalized_url)):
90
+ stop_managed_appium(normalized_url, project_root=root, allow_unmanaged_appium=True)
91
+ pid = _start_appium(normalized_url, appium_bin, sdk_root, log_path, root)
92
+ status = _wait_for_status(normalized_url, startup_timeout_s)
93
+ if status is None:
94
+ raise AppiumEnsureError(f"Appium restart did not become ready; log={log_path}") from exc
95
+ for udid in smoke_udids:
96
+ smoke_appium_session(normalized_url, udid)
97
+ else:
98
+ raise AppiumEnsureError(
99
+ f"Appium smoke session failed for {smoke_udids[0]} at {normalized_url}: {exc}. "
100
+ f"Use `11agents mobile ensure-appium --restart --device Dxx` if a stale local server is occupying the port. "
101
+ f"log={log_path}"
102
+ ) from exc
103
+
104
+ return AppiumEnsureResult(
105
+ status="started" if started else "running",
106
+ server_url=normalized_url,
107
+ appium_bin=str(appium_bin),
108
+ android_sdk_root=str(sdk_root),
109
+ pid=pid or _read_managed_pid(root, normalized_url) or _local_appium_pid(normalized_url),
110
+ log_path=str(log_path),
111
+ smoke_udids=smoke_udids if smoke else [],
112
+ message="ok",
113
+ )
114
+
115
+
116
+ def stop_managed_appium(
117
+ server_url: str = DEFAULT_APPIUM_SERVER,
118
+ *,
119
+ project_root: str | Path | None = None,
120
+ allow_unmanaged_appium: bool = False,
121
+ ) -> bool:
122
+ root = Path(project_root).expanduser() if project_root else find_project_root()
123
+ normalized_url = normalize_server_url(server_url)
124
+ pid_file = _pid_file(root, normalized_url)
125
+ pid = _read_managed_pid(root, normalized_url)
126
+ if not pid and allow_unmanaged_appium:
127
+ pid = _local_appium_pid(normalized_url)
128
+ if not pid:
129
+ if pid_file.exists():
130
+ pid_file.unlink()
131
+ return False
132
+ try:
133
+ os.kill(pid, signal.SIGTERM)
134
+ except ProcessLookupError:
135
+ pid_file.unlink(missing_ok=True)
136
+ return False
137
+ deadline = time.time() + 8
138
+ while time.time() < deadline:
139
+ if not _pid_running(pid):
140
+ pid_file.unlink(missing_ok=True)
141
+ return True
142
+ time.sleep(0.2)
143
+ try:
144
+ os.kill(pid, signal.SIGKILL)
145
+ except ProcessLookupError:
146
+ pass
147
+ pid_file.unlink(missing_ok=True)
148
+ return True
149
+
150
+
151
+ def smoke_appium_session(server_url: str, udid: str) -> None:
152
+ appium = AppiumClient(server_url)
153
+ try:
154
+ appium.start_session(_smoke_capabilities(udid))
155
+ appium.source()
156
+ finally:
157
+ try:
158
+ appium.delete_session()
159
+ except Exception:
160
+ pass
161
+
162
+
163
+ def find_project_root() -> Path:
164
+ candidates = [Path.cwd(), *Path.cwd().parents, Path(__file__).resolve()]
165
+ candidates.extend(Path(__file__).resolve().parents)
166
+ for candidate in candidates:
167
+ root = candidate if candidate.is_dir() else candidate.parent
168
+ if (root / "pyproject.toml").exists() and (root / "src" / "device_control").exists():
169
+ return root
170
+ return Path.cwd()
171
+
172
+
173
+ def resolve_appium_bin(project_root: str | Path | None = None) -> Path:
174
+ root = Path(project_root).expanduser() if project_root else find_project_root()
175
+ env_bin = os.environ.get("APPIUM_BIN", "")
176
+ candidates = [
177
+ Path(env_bin).expanduser() if env_bin else None,
178
+ root / "node_modules" / ".bin" / "appium",
179
+ Path(shutil.which("appium")) if shutil.which("appium") else None,
180
+ ]
181
+ for candidate in candidates:
182
+ if candidate and candidate.exists():
183
+ return candidate
184
+ raise AppiumEnsureError(
185
+ "Appium binary not found. Install project node dependencies or set APPIUM_BIN. "
186
+ f"Expected project binary: {root / 'node_modules' / '.bin' / 'appium'}"
187
+ )
188
+
189
+
190
+ def resolve_android_sdk_root() -> Path:
191
+ env_candidates = [os.environ.get("ANDROID_SDK_ROOT", ""), os.environ.get("ANDROID_HOME", "")]
192
+ candidates: list[Path] = [Path(item).expanduser() for item in env_candidates if item]
193
+ candidates.extend(
194
+ [
195
+ Path.home() / "Library" / "Android" / "sdk",
196
+ Path("/Users/edy/Library/Android/sdk"),
197
+ Path("/opt/homebrew/share/android-commandlinetools"),
198
+ ]
199
+ )
200
+ adb_path = _resolve_adb_path()
201
+ if adb_path and adb_path.parent.name == "platform-tools":
202
+ candidates.append(adb_path.parent.parent)
203
+ for candidate in sorted(Path("/opt/homebrew/Caskroom/android-platform-tools").glob("*"), reverse=True):
204
+ candidates.append(candidate)
205
+
206
+ for candidate in candidates:
207
+ if _has_platform_tools(candidate):
208
+ return candidate
209
+ raise AppiumEnsureError(
210
+ "Android SDK root not found. Set ANDROID_SDK_ROOT or ANDROID_HOME to a directory containing platform-tools/adb."
211
+ )
212
+
213
+
214
+ def normalize_server_url(server_url: str) -> str:
215
+ parsed = urllib.parse.urlparse(server_url or DEFAULT_APPIUM_SERVER)
216
+ scheme = parsed.scheme or "http"
217
+ host = parsed.hostname or "127.0.0.1"
218
+ port = parsed.port or 4723
219
+ return f"{scheme}://{host}:{port}"
220
+
221
+
222
+ def _start_appium(server_url: str, appium_bin: Path, sdk_root: Path, log_path: Path, project_root: Path) -> int:
223
+ parsed = urllib.parse.urlparse(server_url)
224
+ host = parsed.hostname or "127.0.0.1"
225
+ port = str(parsed.port or 4723)
226
+ log_path.parent.mkdir(parents=True, exist_ok=True)
227
+ env = os.environ.copy()
228
+ env["ANDROID_HOME"] = str(sdk_root)
229
+ env["ANDROID_SDK_ROOT"] = str(sdk_root)
230
+ command = [str(appium_bin), "--address", host, "--port", port, "--log-level", "info"]
231
+ log_handle = log_path.open("ab")
232
+ proc = subprocess.Popen(
233
+ command,
234
+ cwd=str(project_root),
235
+ env=env,
236
+ stdout=log_handle,
237
+ stderr=subprocess.STDOUT,
238
+ stdin=subprocess.DEVNULL,
239
+ start_new_session=True,
240
+ close_fds=True,
241
+ )
242
+ _pid_file(project_root, server_url).write_text(
243
+ json.dumps(
244
+ {
245
+ "pid": proc.pid,
246
+ "server_url": server_url,
247
+ "appium_bin": str(appium_bin),
248
+ "android_sdk_root": str(sdk_root),
249
+ "log_path": str(log_path),
250
+ "started_at_epoch": int(time.time()),
251
+ },
252
+ ensure_ascii=False,
253
+ sort_keys=True,
254
+ ),
255
+ encoding="utf-8",
256
+ )
257
+ return proc.pid
258
+
259
+
260
+ def _get_status(server_url: str) -> dict | None:
261
+ try:
262
+ req = urllib.request.Request(f"{server_url}/status", method="GET")
263
+ with urllib.request.urlopen(req, timeout=3) as resp:
264
+ raw = resp.read().decode()
265
+ return json.loads(raw) if raw else {}
266
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
267
+ return None
268
+
269
+
270
+ def _wait_for_status(server_url: str, timeout_s: float) -> dict | None:
271
+ deadline = time.time() + timeout_s
272
+ while time.time() < deadline:
273
+ status = _get_status(server_url)
274
+ if status is not None:
275
+ return status
276
+ time.sleep(0.5)
277
+ return None
278
+
279
+
280
+ def _require_local_server(server_url: str) -> None:
281
+ host = urllib.parse.urlparse(server_url).hostname or ""
282
+ if host not in {"127.0.0.1", "localhost", "::1"}:
283
+ raise AppiumEnsureError(f"Refusing to auto-start Appium for non-local server {server_url}")
284
+
285
+
286
+ def _resolve_adb_path() -> Path | None:
287
+ adb_bin = resolve_adb_bin()
288
+ resolved = shutil.which(adb_bin) or adb_bin
289
+ path = Path(resolved).expanduser()
290
+ try:
291
+ return path.resolve()
292
+ except FileNotFoundError:
293
+ return None
294
+
295
+
296
+ def _has_platform_tools(path: Path) -> bool:
297
+ return path.exists() and (path / "platform-tools" / "adb").exists()
298
+
299
+
300
+ def _state_dir(project_root: Path) -> Path:
301
+ path = project_root / "artifacts" / "appium"
302
+ path.mkdir(parents=True, exist_ok=True)
303
+ return path
304
+
305
+
306
+ def _port_label(server_url: str) -> str:
307
+ parsed = urllib.parse.urlparse(server_url)
308
+ host = (parsed.hostname or "127.0.0.1").replace(":", "_")
309
+ return f"{host}-{parsed.port or 4723}"
310
+
311
+
312
+ def _pid_file(project_root: Path, server_url: str) -> Path:
313
+ return _state_dir(project_root) / f"appium-{_port_label(server_url)}.pid.json"
314
+
315
+
316
+ def _log_path(project_root: Path, server_url: str) -> Path:
317
+ return _state_dir(project_root) / f"appium-{_port_label(server_url)}.log"
318
+
319
+
320
+ def _read_managed_pid(project_root: Path, server_url: str) -> int | None:
321
+ pid_file = _pid_file(project_root, server_url)
322
+ if not pid_file.exists():
323
+ return None
324
+ try:
325
+ payload = json.loads(pid_file.read_text(encoding="utf-8"))
326
+ pid = int(payload.get("pid") or 0)
327
+ except Exception:
328
+ pid_file.unlink(missing_ok=True)
329
+ return None
330
+ if pid and _pid_running(pid):
331
+ return pid
332
+ pid_file.unlink(missing_ok=True)
333
+ return None
334
+
335
+
336
+ def _pid_running(pid: int) -> bool:
337
+ try:
338
+ os.kill(pid, 0)
339
+ return True
340
+ except ProcessLookupError:
341
+ return False
342
+ except PermissionError:
343
+ return True
344
+
345
+
346
+ def _is_managed_process(project_root: Path, server_url: str) -> bool:
347
+ return _read_managed_pid(project_root, server_url) is not None
348
+
349
+
350
+ def _local_appium_pid(server_url: str) -> int | None:
351
+ parsed = urllib.parse.urlparse(server_url)
352
+ host = parsed.hostname or ""
353
+ if host not in {"127.0.0.1", "localhost", "::1"}:
354
+ return None
355
+ port = str(parsed.port or 4723)
356
+ try:
357
+ proc = subprocess.run(
358
+ ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
359
+ capture_output=True,
360
+ text=True,
361
+ check=False,
362
+ timeout=5,
363
+ )
364
+ except (FileNotFoundError, subprocess.TimeoutExpired):
365
+ return None
366
+ for line in proc.stdout.splitlines():
367
+ try:
368
+ pid = int(line.strip())
369
+ except ValueError:
370
+ continue
371
+ if _pid_command_contains(pid, "appium"):
372
+ return pid
373
+ return None
374
+
375
+
376
+ def _pid_command_contains(pid: int, needle: str) -> bool:
377
+ try:
378
+ proc = subprocess.run(
379
+ ["ps", "-p", str(pid), "-o", "command="],
380
+ capture_output=True,
381
+ text=True,
382
+ check=False,
383
+ timeout=5,
384
+ )
385
+ except (FileNotFoundError, subprocess.TimeoutExpired):
386
+ return False
387
+ return needle.lower() in proc.stdout.lower()
388
+
389
+
390
+ def _smoke_capabilities(udid: str) -> dict:
391
+ return {
392
+ "platformName": "Android",
393
+ "appium:automationName": "UiAutomator2",
394
+ "appium:udid": udid,
395
+ "appium:noReset": True,
396
+ "appium:newCommandTimeout": 120,
397
+ "appium:disableWindowAnimation": True,
398
+ "appium:skipDeviceInitialization": True,
399
+ "appium:ignoreHiddenApiPolicyError": True,
400
+ "appium:disableSuppressAccessibilityService": True,
401
+ "appium:settings[enableNotificationListener]": False,
402
+ "appium:autoLaunch": False,
403
+ }