@11agents/cli 0.1.37 → 0.1.38

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.
@@ -1,4 +1,5 @@
1
1
  # 11agents Mobile Data Templates
2
2
 
3
3
  These files seed `~/.11agents/mobile/data` during `11agents mobile setup`.
4
- Replace example devices and accounts with company-owned, authorized Android devices and logged-in accounts before publishing.
4
+ `devices.example.csv` contains the stable D01-D10 device registry. Keep those hardware ids in sync across runtime machines so a physical device keeps the same Dxx id even when it is attached through a different USB port or ADB transport.
5
+ Replace example accounts with company-owned, authorized Android accounts before publishing.
@@ -1,2 +1,11 @@
1
- device_id,adb_serial,ip_address,brand,model,android_version,status,notes
2
- D01,REPLACE_WITH_ADB_SERIAL,,ExampleBrand,ExampleModel,13,active,replace with an authorized company-owned Android device
1
+ device_id,adb_serial,hardware_id,ip_address,brand,model,android_version,status,notes
2
+ D01,e6209341,e6209341,,,,,active,stable device registry slot 1
3
+ D02,7441ce65,7441ce65,,,,,active,stable device registry slot 2
4
+ D03,2eecd64c,2eecd64c,,,,,active,stable device registry slot 3
5
+ D04,bfa3e5ba,bfa3e5ba,,,,,active,stable device registry slot 4
6
+ D05,78d99cb0,78d99cb0,,,,,active,stable device registry slot 5
7
+ D06,2c461593,2c461593,,,,,active,stable device registry slot 6
8
+ D07,2cd62d93,2cd62d93,,,,,active,stable device registry slot 7
9
+ D08,f8cabef1,f8cabef1,,,,,active,stable device registry slot 8
10
+ D09,8b427827,8b427827,,,,,active,stable device registry slot 9
11
+ D10,3185bee8,3185bee8,,,,,active,stable device registry slot 10
@@ -4,7 +4,7 @@ import argparse
4
4
  import csv
5
5
  import json
6
6
  from concurrent.futures import ThreadPoolExecutor, as_completed
7
- from dataclasses import asdict
7
+ from dataclasses import asdict, replace
8
8
  from pathlib import Path
9
9
 
10
10
  from .adb import AdbClient, resolve_adb_bin
@@ -349,7 +349,7 @@ def main() -> None:
349
349
  return
350
350
 
351
351
  if args.command == "ensure-appium":
352
- udids = _resolve_appium_udids(args.devices, args.device, args.udid)
352
+ udids = _resolve_appium_udids(args.devices, args.device, args.udid, adb)
353
353
  try:
354
354
  result = ensure_appium_server(
355
355
  args.server,
@@ -387,12 +387,13 @@ def main() -> None:
387
387
  return
388
388
 
389
389
  if args.command == "list-devices":
390
- devices = load_devices(args.devices)
390
+ devices = _load_devices(args.devices, adb, resolve_transports=args.health)
391
391
  connected = set(adb.list_devices()) if args.health else set()
392
392
  for device in devices.values():
393
393
  row = {
394
394
  "device_id": device.device_id,
395
395
  "adb_serial": device.adb_serial,
396
+ "hardware_id": device.hardware_id,
396
397
  "ip_address": device.ip_address,
397
398
  "brand": device.brand,
398
399
  "model": device.model,
@@ -413,7 +414,7 @@ def main() -> None:
413
414
  if args.health:
414
415
  suffix = f" online={row['online']} battery={row.get('battery', '')} focus={row.get('focus', '')}"
415
416
  print(
416
- f"{device.device_id} serial={device.adb_serial} ip={device.ip_address} "
417
+ f"{device.device_id} serial={device.adb_serial} hardware_id={device.hardware_id} ip={device.ip_address} "
417
418
  f"model={device.model} status={device.status}{suffix}"
418
419
  )
419
420
  return
@@ -428,6 +429,7 @@ def main() -> None:
428
429
  row = {
429
430
  "device_id": args.device_id,
430
431
  "adb_serial": serial,
432
+ "hardware_id": _device_hardware_id(adb, serial) or serial,
431
433
  "ip_address": _ip_from_serial(serial),
432
434
  "brand": adb.getprop(serial, "ro.product.brand"),
433
435
  "model": adb.getprop(serial, "ro.product.model"),
@@ -443,33 +445,34 @@ def main() -> None:
443
445
  return
444
446
 
445
447
  if args.command == "connect":
446
- devices = load_devices(args.devices)
448
+ devices = _load_devices(args.devices, adb, resolve_transports=False)
447
449
  for device in devices.values():
448
450
  if device.status != "active":
449
451
  print(f"{device.device_id}: skipped status={device.status}")
450
452
  continue
451
- result = adb.connect(device.adb_serial)
453
+ target = device.ip_address or device.adb_serial
454
+ result = adb.connect(target)
452
455
  status = "ok" if result.ok else "fail"
453
- print(f"{device.device_id} {device.adb_serial}: {status} {result.stdout or result.stderr}")
456
+ print(f"{device.device_id} {target}: {status} {result.stdout or result.stderr}")
454
457
  return
455
458
 
456
459
  if args.command == "health":
457
- devices = load_devices(args.devices)
460
+ devices = _load_devices(args.devices, adb)
458
461
  connected = set(adb.list_devices())
459
462
  for device in devices.values():
460
463
  online = device.adb_serial in connected
461
464
  if not online:
462
- print(f"{device.device_id}: offline serial={device.adb_serial}")
465
+ print(f"{device.device_id}: offline serial={device.adb_serial} hardware_id={device.hardware_id}")
463
466
  continue
464
467
  battery = adb.battery_level(device.adb_serial)
465
468
  model = adb.getprop(device.adb_serial, "ro.product.model")
466
469
  android = adb.getprop(device.adb_serial, "ro.build.version.release")
467
470
  focus = adb.current_focus(device.adb_serial)
468
- print(f"{device.device_id}: online battery={battery} model={model} android={android} focus={focus}")
471
+ print(f"{device.device_id}: online battery={battery} serial={device.adb_serial} hardware_id={device.hardware_id} model={model} android={android} focus={focus}")
469
472
  return
470
473
 
471
474
  if args.command == "screenshot":
472
- device = _get_device(args.devices, args.device_id)
475
+ device = _get_device(args.devices, args.device_id, adb)
473
476
  out = Path(args.out_dir) / f"{device.device_id}.png"
474
477
  result = adb.screenshot(device.adb_serial, out)
475
478
  if result.ok:
@@ -479,7 +482,7 @@ def main() -> None:
479
482
  return
480
483
 
481
484
  if args.command == "dump-ui":
482
- device = _get_device(args.devices, args.device_id)
485
+ device = _get_device(args.devices, args.device_id, adb)
483
486
  out = Path(args.out_dir) / f"{device.device_id}.xml"
484
487
  result = adb.dump_ui(device.adb_serial, out)
485
488
  if result.ok:
@@ -490,25 +493,25 @@ def main() -> None:
490
493
  return
491
494
 
492
495
  if args.command == "tap":
493
- device = _get_device(args.devices, args.device_id)
496
+ device = _get_device(args.devices, args.device_id, adb)
494
497
  result = adb.tap(device.adb_serial, args.x, args.y)
495
498
  print(result.stdout or result.stderr)
496
499
  return
497
500
 
498
501
  if args.command == "keyevent":
499
- device = _get_device(args.devices, args.device_id)
502
+ device = _get_device(args.devices, args.device_id, adb)
500
503
  result = adb.keyevent(device.adb_serial, args.keycode)
501
504
  print(result.stdout or result.stderr)
502
505
  return
503
506
 
504
507
  if args.command == "input-text":
505
- device = _get_device(args.devices, args.device_id)
508
+ device = _get_device(args.devices, args.device_id, adb)
506
509
  result = adb.input_text(device.adb_serial, args.text)
507
510
  print(result.stdout or result.stderr)
508
511
  return
509
512
 
510
513
  if args.command == "launch":
511
- device = _get_device(args.devices, args.device_id)
514
+ device = _get_device(args.devices, args.device_id, adb)
512
515
  result = adb.launch_package(device.adb_serial, args.package)
513
516
  print(result.stdout or result.stderr)
514
517
  return
@@ -632,7 +635,7 @@ def main() -> None:
632
635
  return
633
636
 
634
637
  if args.command == "collect-tiktok-account-metrics-adb":
635
- device = _get_device(args.devices, args.device)
638
+ device = _get_device(args.devices, args.device, adb)
636
639
  account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
637
640
  manual_values = _load_tiktok_account_metric_values(args.values_json, args.values_file)
638
641
  collector = TikTokAccountMetricsAdbCollector(adb, artifact_root=args.artifact_root)
@@ -673,7 +676,7 @@ def main() -> None:
673
676
  return
674
677
 
675
678
  if args.command == "collect-tiktok-video-metrics-adb":
676
- device = _get_device(args.devices, args.device)
679
+ device = _get_device(args.devices, args.device, adb)
677
680
  account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
678
681
  manual_values = _load_tiktok_video_metric_values(args.values_json, args.values_file)
679
682
  collector = TikTokVideoMetricsAdbCollector(adb, artifact_root=args.artifact_root)
@@ -715,7 +718,7 @@ def main() -> None:
715
718
  return
716
719
 
717
720
  if args.command == "publish-tiktok-adb":
718
- devices = load_devices(args.devices)
721
+ devices = _load_devices(args.devices, adb)
719
722
  selected = _select_devices(devices, args.device, args.ranges)
720
723
  if not selected:
721
724
  raise SystemExit("No target devices. Use --device D01 or --range 1-10.")
@@ -751,7 +754,7 @@ def main() -> None:
751
754
  return
752
755
 
753
756
  if args.command in {"publish-tiktok", "publish-tiktok-appium"}:
754
- devices = load_devices(args.devices)
757
+ devices = _load_devices(args.devices, adb)
755
758
  selected = _select_devices(devices, args.device, args.ranges)
756
759
  if not selected:
757
760
  raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
@@ -787,7 +790,7 @@ def main() -> None:
787
790
  return
788
791
 
789
792
  if args.command in {"publish-reddit", "publish-reddit-adb"}:
790
- devices = load_devices(args.devices)
793
+ devices = _load_devices(args.devices, adb)
791
794
  selected = _select_devices(devices, args.device, args.ranges)
792
795
  if not selected:
793
796
  raise SystemExit("No target devices. Use --device D01 or --range 1-10.")
@@ -832,7 +835,7 @@ def main() -> None:
832
835
  return
833
836
 
834
837
  if args.command == "publish-facebook":
835
- devices = load_devices(args.devices)
838
+ devices = _load_devices(args.devices, adb)
836
839
  selected = _select_devices(devices, args.device, args.ranges)
837
840
  if not selected:
838
841
  raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
@@ -871,7 +874,7 @@ def main() -> None:
871
874
 
872
875
  if args.command == "publish-instagram":
873
876
  _apply_instagram_publish_policy(args)
874
- devices = load_devices(args.devices)
877
+ devices = _load_devices(args.devices, adb)
875
878
  selected = _select_devices(devices, args.device, args.ranges)
876
879
  if not selected:
877
880
  raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
@@ -911,7 +914,7 @@ def main() -> None:
911
914
  return
912
915
 
913
916
  if args.command == "publish-x":
914
- devices = load_devices(args.devices)
917
+ devices = _load_devices(args.devices, adb)
915
918
  selected = _select_devices(devices, args.device, args.ranges)
916
919
  if not selected:
917
920
  raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
@@ -955,7 +958,7 @@ def main() -> None:
955
958
  if args.command == "publish-xiaohongshu":
956
959
  _apply_xiaohongshu_publish_package(args)
957
960
  _apply_xiaohongshu_live_publish_policy(args)
958
- devices = load_devices(args.devices)
961
+ devices = _load_devices(args.devices, adb)
959
962
  selected = _select_devices(devices, args.device, args.ranges)
960
963
  if not selected:
961
964
  raise SystemExit("No target devices. Use --device Dxx or --range 1-5.")
@@ -1011,7 +1014,7 @@ def main() -> None:
1011
1014
  device_id = args.device or (record.device_id if record else "")
1012
1015
  if not device_id:
1013
1016
  raise SystemExit("copy-xiaohongshu-link requires --device or --record-id for a record with device_id")
1014
- device = _get_device(args.devices, device_id)
1017
+ device = _get_device(args.devices, device_id, adb)
1015
1018
  _ensure_appium_preflight(args, [device])
1016
1019
  publisher = XiaohongshuAdbPublisher(
1017
1020
  adb,
@@ -1048,17 +1051,77 @@ def main() -> None:
1048
1051
  return
1049
1052
 
1050
1053
 
1051
- def _get_device(devices_path: str, device_id: str):
1054
+ DEVICE_IDENTITY_PROPS = (
1055
+ "ro.serialno",
1056
+ "ro.boot.serialno",
1057
+ "ro.vendor.boot.serialno",
1058
+ "ro.product.serialno",
1059
+ )
1060
+
1061
+
1062
+ def _load_devices(devices_path: str, adb: AdbClient, *, resolve_transports: bool = True):
1052
1063
  devices = load_devices(devices_path)
1064
+ if not resolve_transports:
1065
+ return devices
1066
+ return _resolve_device_transports(devices, adb)
1067
+
1068
+
1069
+ def _resolve_device_transports(devices: dict[str, object], adb: AdbClient):
1070
+ transport_by_identity = _connected_transport_by_identity(adb)
1071
+ if not transport_by_identity:
1072
+ return devices
1073
+ resolved = {}
1074
+ for device_id, device in devices.items():
1075
+ transport = ""
1076
+ for identity in _device_identity_candidates(device):
1077
+ transport = transport_by_identity.get(identity)
1078
+ if transport:
1079
+ break
1080
+ resolved[device_id] = replace(device, adb_serial=transport) if transport and transport != device.adb_serial else device
1081
+ return resolved
1082
+
1083
+
1084
+ def _connected_transport_by_identity(adb: AdbClient) -> dict[str, str]:
1085
+ out: dict[str, str] = {}
1086
+ for transport in adb.list_devices():
1087
+ for identity in {transport, _device_hardware_id(adb, transport)}:
1088
+ clean = _clean_device_identity(identity)
1089
+ if clean:
1090
+ out[clean] = transport
1091
+ return out
1092
+
1093
+
1094
+ def _device_hardware_id(adb: AdbClient, transport: str) -> str:
1095
+ for prop in DEVICE_IDENTITY_PROPS:
1096
+ value = adb.getprop(transport, prop).strip()
1097
+ if value and value.lower() not in {"unknown", "null", "none"}:
1098
+ return value
1099
+ return ""
1100
+
1101
+
1102
+ def _device_identity_candidates(device) -> list[str]:
1103
+ candidates = [
1104
+ getattr(device, "hardware_id", ""),
1105
+ getattr(device, "adb_serial", ""),
1106
+ ]
1107
+ return [clean for clean in (_clean_device_identity(item) for item in candidates) if clean]
1108
+
1109
+
1110
+ def _clean_device_identity(value: str) -> str:
1111
+ return str(value or "").strip().lower()
1112
+
1113
+
1114
+ def _get_device(devices_path: str, device_id: str, adb: AdbClient):
1115
+ devices = _load_devices(devices_path, adb)
1053
1116
  if device_id not in devices:
1054
1117
  raise SystemExit(f"Unknown device_id={device_id}")
1055
1118
  return devices[device_id]
1056
1119
 
1057
1120
 
1058
- def _resolve_appium_udids(devices_path: str, device_ids: list[str], udids: list[str]) -> list[str]:
1121
+ def _resolve_appium_udids(devices_path: str, device_ids: list[str], udids: list[str], adb: AdbClient) -> list[str]:
1059
1122
  resolved = [item for item in udids if item]
1060
1123
  if device_ids:
1061
- devices = load_devices(devices_path)
1124
+ devices = _load_devices(devices_path, adb)
1062
1125
  for device_id in device_ids:
1063
1126
  if device_id not in devices:
1064
1127
  raise SystemExit(f"Unknown device_id={device_id}")
@@ -11,6 +11,7 @@ class Device:
11
11
  device_id: str
12
12
  adb_serial: str
13
13
  ip_address: str
14
+ hardware_id: str = ""
14
15
  brand: str = ""
15
16
  model: str = ""
16
17
  android_version: str = ""
@@ -23,6 +23,7 @@ def load_devices(path: str | Path) -> dict[str, Device]:
23
23
  device_id=row["device_id"],
24
24
  adb_serial=row["adb_serial"],
25
25
  ip_address=row.get("ip_address", ""),
26
+ hardware_id=row.get("hardware_id", "") or row.get("physical_id", "") or row["adb_serial"],
26
27
  brand=row.get("brand", ""),
27
28
  model=row.get("model", ""),
28
29
  android_version=row.get("android_version", ""),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@11agents/cli",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "11agents local runtime and telemetry CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -235,6 +235,104 @@ async function writeFileIfMissing(filePath, content) {
235
235
  }
236
236
  }
237
237
 
238
+ async function mergeDeviceRegistry(filePath, templateContent) {
239
+ let currentContent = ''
240
+ try {
241
+ currentContent = await readFile(filePath, 'utf8')
242
+ } catch {
243
+ await mkdir(path.dirname(filePath), { recursive: true })
244
+ await writeFile(filePath, templateContent)
245
+ return
246
+ }
247
+ const current = parseCsv(currentContent)
248
+ const template = parseCsv(templateContent)
249
+ const headers = [...current.headers]
250
+ for (const header of template.headers) {
251
+ if (!headers.includes(header)) headers.push(header)
252
+ }
253
+ const rows = current.rows.map(row => ({ ...row }))
254
+ const rowByDeviceId = new Map(rows.map(row => [row.device_id, row]))
255
+ for (const seed of template.rows) {
256
+ const deviceId = seed.device_id
257
+ const existing = rowByDeviceId.get(deviceId)
258
+ if (!existing) {
259
+ rows.push({ ...seed })
260
+ rowByDeviceId.set(deviceId, rows[rows.length - 1])
261
+ continue
262
+ }
263
+ if (isCanonicalDeviceId(deviceId)) {
264
+ const managedCanonicalRow = /^stable device registry slot/i.test(existing.notes || '')
265
+ const serialChanged = existing.adb_serial && existing.adb_serial !== seed.adb_serial
266
+ existing.adb_serial = seed.adb_serial
267
+ existing.hardware_id = seed.hardware_id || seed.adb_serial
268
+ if (managedCanonicalRow || serialChanged) {
269
+ existing.brand = seed.brand || ''
270
+ existing.model = seed.model || ''
271
+ existing.android_version = seed.android_version || ''
272
+ }
273
+ if (!existing.status) existing.status = seed.status || 'active'
274
+ if (!existing.notes || /replace with|single-device POC/i.test(existing.notes)) existing.notes = seed.notes || ''
275
+ }
276
+ }
277
+ await writeFile(filePath, formatCsv(headers, rows))
278
+ }
279
+
280
+ function isCanonicalDeviceId(deviceId = '') {
281
+ return /^D(?:0[1-9]|10)$/.test(String(deviceId))
282
+ }
283
+
284
+ function parseCsv(text = '') {
285
+ const lines = String(text || '').split(/\r?\n/).filter(line => line.length)
286
+ if (!lines.length) return { headers: [], rows: [] }
287
+ const headers = parseCsvLine(lines[0])
288
+ return {
289
+ headers,
290
+ rows: lines.slice(1).map(line => {
291
+ const values = parseCsvLine(line)
292
+ return Object.fromEntries(headers.map((header, index) => [header, values[index] || '']))
293
+ }),
294
+ }
295
+ }
296
+
297
+ function parseCsvLine(line = '') {
298
+ const out = []
299
+ let value = ''
300
+ let quoted = false
301
+ for (let i = 0; i < line.length; i += 1) {
302
+ const ch = line[i]
303
+ if (ch === '"') {
304
+ if (quoted && line[i + 1] === '"') {
305
+ value += '"'
306
+ i += 1
307
+ } else {
308
+ quoted = !quoted
309
+ }
310
+ continue
311
+ }
312
+ if (ch === ',' && !quoted) {
313
+ out.push(value)
314
+ value = ''
315
+ continue
316
+ }
317
+ value += ch
318
+ }
319
+ out.push(value)
320
+ return out
321
+ }
322
+
323
+ function formatCsv(headers, rows) {
324
+ return [
325
+ headers.join(','),
326
+ ...rows.map(row => headers.map(header => csvCell(row[header] || '')).join(',')),
327
+ ].join('\n') + '\n'
328
+ }
329
+
330
+ function csvCell(value) {
331
+ const text = String(value ?? '')
332
+ if (!/[",\n\r]/.test(text)) return text
333
+ return `"${text.replaceAll('"', '""')}"`
334
+ }
335
+
238
336
  async function scaffoldMobileHome(home) {
239
337
  await mkdir(home, { recursive: true })
240
338
  await mkdir(path.join(home, 'data'), { recursive: true })
@@ -248,7 +346,7 @@ async function scaffoldMobileHome(home) {
248
346
  path.join(home, 'data', 'accounts.csv'),
249
347
  await readFile(path.join(MOBILE_DATA_TEMPLATE_ROOT, 'accounts.example.csv'), 'utf8')
250
348
  )
251
- await writeFileIfMissing(
349
+ await mergeDeviceRegistry(
252
350
  path.join(home, 'data', 'devices.csv'),
253
351
  await readFile(path.join(MOBILE_DATA_TEMPLATE_ROOT, 'devices.example.csv'), 'utf8')
254
352
  )