webmuxd 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
webmuxd/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """webmuxd —— tmux + ttyd,只是 pane 里渲染的不是 tty 字符,是浏览器像素。
2
+
3
+ 三个对象,一层套一层(docs/v1/sdk/README.md §1):
4
+
5
+ web = Webmuxd() # 管理实例,空壳
6
+ sess = web.session(id="work", port=7900, vnc_port=6901)
7
+ tab = sess.open("https://example.com")
8
+ """
9
+
10
+ from webmuxd.client import ( # noqa: F401
11
+ Webmuxd, Session, Tab, ActResult, Observation, Element,
12
+ )
13
+ from webmuxd.errors import ( # noqa: F401
14
+ WebmuxdError,
15
+ ActionError,
16
+ PlatformError,
17
+ UsageError,
18
+ NotFound,
19
+ NotClickable,
20
+ Timeout,
21
+ NavFailed,
22
+ TabGone,
23
+ Busy,
24
+ BusyHuman,
25
+ ChromeGone,
26
+ SessionDead,
27
+ RuntimeUnavailable,
28
+ PortInUse,
29
+ BadRequest,
30
+ BlockedURL,
31
+ ReadOnly,
32
+ SessionExists,
33
+ SessionNotFound,
34
+ )
35
+
36
+ __version__ = "0.1.0"
37
+ __all__ = ["__version__", "Webmuxd", "Session", "Tab", "ActResult",
38
+ "Observation", "Element"] + [
39
+ "WebmuxdError", "ActionError", "PlatformError", "UsageError",
40
+ "NotFound", "NotClickable", "Timeout", "NavFailed", "TabGone",
41
+ "Busy", "BusyHuman", "ChromeGone", "SessionDead", "RuntimeUnavailable",
42
+ "PortInUse", "BadRequest", "BlockedURL", "ReadOnly", "SessionExists",
43
+ "SessionNotFound",
44
+ ]
webmuxd/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """`python -m webmuxd` == `webmuxd`。"""
2
+
3
+ import sys
4
+
5
+ from webmuxd.cli.__main__ import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,587 @@
1
+ """`webmuxd` 命令行 —— docs/v1/cli/。
2
+
3
+ 照着 tmux 设计。用过 tmux 的人应该不用查文档。
4
+
5
+ **CLI 是 lib 的一个用户**,和你的代码平级 —— 它自己不实现任何行为,
6
+ 每条命令就是一次 lib 调用。多出来的只有两样,都是终端才需要的:
7
+ **目标解析**(`-t work:购物车` 按标题匹配)和**输出格式化**。
8
+
9
+ 退出码是给脚本用的契约,**不要靠解析输出**(cli/README §6)。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import sys
18
+ from typing import Any
19
+
20
+ from webmuxd import Webmuxd, runtime as rt
21
+ from webmuxd.cli.registry import Registry
22
+ from webmuxd.client.session import Session
23
+ from webmuxd.client.tab import Tab
24
+ from webmuxd.errors import WebmuxdError
25
+
26
+ # 退出码 → 错误码(cli/README §6)。4/5/6 可重试,7 该告警。
27
+ EXIT = {
28
+ "bad_request": 2, "blocked_url": 2,
29
+ "session_not_found": 3, "tab_gone": 3, "session_exists": 3,
30
+ "not_found": 4, "not_clickable": 4,
31
+ "timeout": 5,
32
+ "busy": 6, "busy_human": 6,
33
+ "chrome_gone": 7, "session_dead": 7, "runtime_unavailable": 7,
34
+ "port_in_use": 7,
35
+ }
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ p = _parser()
40
+ args = p.parse_args(argv)
41
+ if not getattr(args, "cmd", None):
42
+ p.print_help()
43
+ return 2
44
+ try:
45
+ return args.fn(args) or 0
46
+ except WebmuxdError as e:
47
+ print(f"✗ {e.code}: {e.message}", file=sys.stderr)
48
+ for c in (e.details.get("candidates") or [])[:5]:
49
+ print(f" 候选: {c.get('role','') :8} \"{c.get('name','')}\"",
50
+ file=sys.stderr)
51
+ if e.details.get("hint"):
52
+ print(f" {e.details['hint']}", file=sys.stderr)
53
+ return EXIT.get(e.code or "", 1)
54
+ except BrokenPipeError:
55
+ return 0
56
+ except KeyboardInterrupt:
57
+ return 130
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # 目标解析 —— `session[:tab]`,**全在客户端做**,服务端只认 tab id
62
+ # ---------------------------------------------------------------------------
63
+
64
+ def _split_target(t: str | None) -> tuple[str | None, str | None]:
65
+ t = t or os.environ.get("WEBMUXD_TARGET") or None
66
+ if not t:
67
+ return None, None
68
+ sid, _, tab = t.partition(":")
69
+ return (sid or None), (tab or None)
70
+
71
+
72
+ def _manager(args: argparse.Namespace) -> Webmuxd:
73
+ return Webmuxd(args.host, token=os.environ.get("WEBMUXD_TOKEN"),
74
+ user=args.user or os.environ.get("WEBMUXD_USER") or "cli",
75
+ name=args.socket_name)
76
+
77
+
78
+ def _session(args: argparse.Namespace) -> Session:
79
+ sid, _tab = _split_target(args.target)
80
+ reg = Registry(name=args.socket_name)
81
+ rows = reg.list()
82
+ if sid is None:
83
+ live = [r for r in rows if r["state"] == "ready"]
84
+ if len(live) != 1:
85
+ # **不猜** —— 点错浏览器的代价比敲错终端大(cli/README §2)
86
+ raise WebmuxdError(
87
+ f"有 {len(live)} 个 session,得用 -t 指定" if live
88
+ else "没有在跑的 session", code="session_not_found")
89
+ sid = live[0]["id"]
90
+
91
+ row = reg.get(sid)
92
+ if row is None:
93
+ raise WebmuxdError(f"没有叫 {sid!r} 的 session", code="session_not_found")
94
+ return _manager(args).session(id=sid, port=row["api_port"],
95
+ vnc_port=row.get("vnc_port") or None)
96
+
97
+
98
+ def _tab(args: argparse.Namespace) -> Tab:
99
+ """`work:2` / `work:t_7` / `work:购物车` —— 后两种都是本地匹配。"""
100
+ sess = _session(args)
101
+ _sid, key = _split_target(args.target)
102
+ if not key:
103
+ t = sess.active
104
+ if t is None:
105
+ raise WebmuxdError("这个 session 一个 tab 都没有", code="tab_gone")
106
+ return t
107
+ if key.isdigit():
108
+ return sess.tab(int(key))
109
+ if key.startswith("t_"):
110
+ return sess.tab(key)
111
+ return sess.tab(title=key)
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # 输出
116
+ # ---------------------------------------------------------------------------
117
+
118
+ def _out(args: argparse.Namespace, payload: Any, text: str = "") -> None:
119
+ """`--json` 吐 API 的**原始响应**,不做格式化 —— 方便和 API 混着用。"""
120
+ if getattr(args, "json", False):
121
+ print(json.dumps(payload, ensure_ascii=False))
122
+ elif text:
123
+ print(text)
124
+
125
+
126
+ def _fmt(template: str, **fields: Any) -> str:
127
+ out = template
128
+ for k, v in fields.items():
129
+ out = out.replace("#{" + k + "}", "" if v is None else str(v))
130
+ return out
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # 会话
135
+ # ---------------------------------------------------------------------------
136
+
137
+ def cmd_new(args: argparse.Namespace) -> int:
138
+ # **命令行 > 环境变量 > 内置默认。** 没有配置文件那一档 ——
139
+ # 参数从 lib 传(sdk/manager.md),CLI 只是把它们摆成 flag。
140
+ runtime = args.runtime or rt.default()
141
+ url = args.url or os.environ.get("WEBMUXD_START_URL") or "about:blank"
142
+ viewport = args.viewport or os.environ.get("WEBMUXD_VIEWPORT") or "1280x800"
143
+
144
+ reg = Registry(name=args.socket_name)
145
+ impl = rt.get(runtime)
146
+ if reg.get(args.id) and reg.list():
147
+ row = next((r for r in reg.list() if r["id"] == args.id), None)
148
+ if row and row["state"] == "ready":
149
+ # **幂等** —— 同一个 id 再建一次就是接管,不报错(像 tmux new -A -s)
150
+ print(f"{args.id} → 已经在跑了")
151
+ return 0
152
+ reg.forget(args.id)
153
+
154
+ handle = impl.start(args.id, api_port=args.port, vnc_port=args.vnc_port or 0,
155
+ url=url, viewport=viewport,
156
+ volume=args.volume, proxy=args.proxy,
157
+ endpoint=args.endpoint,
158
+ token=os.environ.get("WEBMUXD_TOKEN"))
159
+ reg.put(handle)
160
+ _out(args, {"id": args.id, "api_port": handle.api_port,
161
+ "vnc_port": handle.vnc_port,
162
+ "vnc_url": handle.vnc_url, "api_url": handle.api_url,
163
+ "vnc_user": handle.detail.get("vnc_user", ""),
164
+ "vnc_password": handle.detail.get("vnc_password", "")},
165
+ f"{args.id} → 画面 {handle.vnc_url or '(无)'} API {handle.api_url}")
166
+ if not args.json and handle.detail.get("vnc_password"):
167
+ # 密码是起的时候现生成的,**这是唯一会说出来的一次**
168
+ print(f" 登录 {handle.detail.get('vnc_user')} / "
169
+ f"{handle.detail['vnc_password']} (自签名证书,浏览器会拦一下)")
170
+ for note in handle.detail.get("notes") or []:
171
+ print(f" ⚠ {note}", file=sys.stderr)
172
+ return 0
173
+
174
+
175
+ def cmd_ls(args: argparse.Namespace) -> int:
176
+ rows = Registry(name=args.socket_name).list()
177
+ _out(args, {"sessions": rows})
178
+ if args.json:
179
+ return 0
180
+ if not rows:
181
+ print("(没有 session)")
182
+ return 0
183
+ for r in rows:
184
+ ports = f"{r.get('vnc_port') or '-'}/{r['api_port']}"
185
+ tail = "" if r["state"] == "ready" else \
186
+ f" dead — webmuxd kill -t {r['id']} 清掉"
187
+ print(f"{r['id']:<10} {r['runtime']:<10} {ports:<12} {r['state']}{tail}")
188
+ return 0
189
+
190
+
191
+ def cmd_has(args: argparse.Namespace) -> int:
192
+ """只返回退出码,给脚本用:`webmuxd has -t work || webmuxd new ...`"""
193
+ sid, _ = _split_target(args.target)
194
+ row = next((r for r in Registry(name=args.socket_name).list()
195
+ if r["id"] == sid), None)
196
+ return 0 if row and row["state"] == "ready" else 3
197
+
198
+
199
+ def cmd_kill(args: argparse.Namespace) -> int:
200
+ sid, _ = _split_target(args.target)
201
+ reg = Registry(name=args.socket_name)
202
+ row = reg.get(sid)
203
+ if row is None:
204
+ raise WebmuxdError(f"没有叫 {sid!r} 的 session", code="session_not_found")
205
+ handle = reg.handle(sid)
206
+ impl = rt.get(row["runtime"])
207
+ impl.stop(handle)
208
+ reg.forget(sid)
209
+ note = "(remote session,对面仍在运行)" if row["runtime"] == "remote" else ""
210
+ _out(args, {"id": sid, "removed": True}, f"{sid} 已停掉 {note}".strip())
211
+ return 0
212
+
213
+
214
+ def cmd_install(args: argparse.Namespace) -> int:
215
+ from webmuxd.cli.install import install
216
+ install(pull=not args.no_pull)
217
+ return 0
218
+
219
+
220
+ def cmd_info(args: argparse.Namespace) -> int:
221
+ from webmuxd import env
222
+ rows = Registry(name=args.socket_name).list()
223
+ rec = env.load()
224
+ info = {"version": "0.1.0", "runtimes": rt.detect(),
225
+ "default_runtime": rt.default(),
226
+ "env_record": {"at": rec["at"]} if rec else None,
227
+ "sessions": {"total": len(rows),
228
+ "ready": sum(1 for r in rows if r["state"] == "ready"),
229
+ "dead": sum(1 for r in rows if r["state"] != "ready")}}
230
+ _out(args, info,
231
+ "\n".join([f"版本 {info['version']}",
232
+ f"runtime " + ", ".join(
233
+ f"{k}{'' if v else '(不可用)'}"
234
+ for k, v in info["runtimes"].items()),
235
+ f"session {info['sessions']['ready']} 在跑,"
236
+ f"{info['sessions']['dead']} 死了",
237
+ (f"记录 {rec['at']}(webmuxd install 探的)" if rec
238
+ else "记录 没有 —— 每次都现探,"
239
+ "跑 `webmuxd install` 可以省掉")]))
240
+ return 0
241
+
242
+
243
+ def cmd_attach(args: argparse.Namespace) -> int:
244
+ sid, _ = _split_target(args.target)
245
+ row = Registry(name=args.socket_name).get(sid)
246
+ if not row:
247
+ raise WebmuxdError(f"没有叫 {sid!r} 的 session", code="session_not_found")
248
+ if not row.get("vnc_port"):
249
+ raise WebmuxdError(f"{sid} 没有画面(起它的时候没有 VNC)",
250
+ code="bad_request")
251
+ url = f"http://127.0.0.1:{row['vnc_port']}"
252
+ print(url)
253
+ if not args.print_only:
254
+ import webbrowser
255
+ webbrowser.open(url)
256
+ return 0
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # tab
261
+ # ---------------------------------------------------------------------------
262
+
263
+ def cmd_tabs(args: argparse.Namespace) -> int:
264
+ sess = _session(args)
265
+ tabs = sess.tabs
266
+ _out(args, {"tabs": [t._row() for t in tabs],
267
+ "active": sess._mirror.active})
268
+ if args.json:
269
+ return 0
270
+ for t in tabs:
271
+ row = t._row()
272
+ if args.format:
273
+ print(_fmt(args.format, tab_id=t.id, tab_index=row.get("index"),
274
+ tab_title=row.get("title"), tab_url=row.get("url"),
275
+ tab_active=1 if t.active else "",
276
+ tab_loading=1 if row.get("loading") else "",
277
+ session_name=sess.id, tab_count=len(tabs)))
278
+ else:
279
+ print(f"{row.get('index', 0)}: {row.get('title','') :<14} "
280
+ f"{row.get('url','')} {'●' if t.active else ''}")
281
+ return 0
282
+
283
+
284
+ def cmd_new_tab(args: argparse.Namespace) -> int:
285
+ sess = _session(args)
286
+ tab = sess.open(args.url or "about:blank", active=not args.no_switch)
287
+ _out(args, tab._row(), f"✓ {tab.id} {tab.title}")
288
+ return 0
289
+
290
+
291
+ def cmd_select_tab(args: argparse.Namespace) -> int:
292
+ t = _tab(args)
293
+ t.activate()
294
+ _out(args, t._row(), f"✓ 切到 {t.id}")
295
+ return 0
296
+
297
+
298
+ def cmd_kill_tab(args: argparse.Namespace) -> int:
299
+ t = _tab(args)
300
+ r = t.close()
301
+ created = (r or {}).get("created")
302
+ _out(args, r, f"✓ 关掉 {t.id}" +
303
+ (f";只剩它了,已新建 about:blank ({created['id']})" if created else ""))
304
+ return 0
305
+
306
+
307
+ def _nav(verb: str):
308
+ def run(args: argparse.Namespace) -> int:
309
+ t = _tab(args)
310
+ if verb == "goto":
311
+ t.goto(args.url)
312
+ else:
313
+ getattr(t, verb)()
314
+ _out(args, t._row(), f"✓ {verb} {t.url}")
315
+ return 0
316
+ return run
317
+
318
+
319
+ def cmd_dialog(args: argparse.Namespace) -> int:
320
+ t = _tab(args)
321
+ t.answer(accept=not args.dismiss, text=args.text or "")
322
+ _out(args, {"ok": True}, "✓ 已" + ("取消" if args.dismiss else "确定"))
323
+ return 0
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # 操作
328
+ # ---------------------------------------------------------------------------
329
+
330
+ def _locator(args: argparse.Namespace) -> dict:
331
+ spec: dict[str, Any] = {}
332
+ if getattr(args, "what", None):
333
+ spec["text"] = args.what
334
+ for src, dst in (("role", "role"), ("name", "name"), ("label", "label"),
335
+ ("css", "css"), ("nth", "nth")):
336
+ v = getattr(args, src, None)
337
+ if v is not None:
338
+ spec[dst] = v
339
+ if getattr(args, "at", None):
340
+ spec["point"] = [float(x) for x in args.at.split(",")]
341
+ return spec
342
+
343
+
344
+ def _do(args: argparse.Namespace, spec: dict) -> int:
345
+ t = _tab(args)
346
+ r = t.act([spec], note=args.note, user=args.user)
347
+ res = r.results[0]
348
+ _out(args, {"results": r.results, "log_from": r.log_from})
349
+ if not res.get("ok"):
350
+ raise WebmuxdError(res.get("message", ""), code=res.get("error"),
351
+ details={"candidates": res.get("candidates") or []})
352
+ if not args.json:
353
+ hit = res.get("hit") or {}
354
+ print(f"✓ {spec['type']} → {hit.get('role','')} \"{hit.get('name','')}\""
355
+ f" {res.get('ms',0)}ms")
356
+ after = res.get("after") or {}
357
+ if after.get("changed"):
358
+ print(f" → {after.get('url','')} {after['changed']}")
359
+ return 0
360
+
361
+
362
+ def cmd_click(args): return _do(args, {"type": "click", **_locator(args)})
363
+ def cmd_key(args): return _do(args, {"type": "key", "key": args.key})
364
+
365
+
366
+ def cmd_type(args: argparse.Namespace) -> int:
367
+ spec = {"type": "type", "text": args.text, **_locator(args)}
368
+ spec.pop("text", None) if False else None
369
+ # `type` 的定位不看 text —— 那是内容(api/act.md §4.1)
370
+ if "text" in spec and getattr(args, "what", None):
371
+ spec["label"] = spec.pop("text")
372
+ spec["text"] = args.text
373
+ return _do(args, spec)
374
+
375
+
376
+ def cmd_scroll(args): return _do(args, {"type": "scroll", "dy": args.dy})
377
+
378
+
379
+ def cmd_wait(args: argparse.Namespace) -> int:
380
+ spec: dict[str, Any] = {"type": "wait_for",
381
+ "timeout_ms": int(args.timeout * 1000)}
382
+ for k in ("text", "css", "url_contains"):
383
+ v = getattr(args, k, None)
384
+ if v:
385
+ spec[k] = v
386
+ return _do(args, spec)
387
+
388
+
389
+ def cmd_send(args: argparse.Namespace) -> int:
390
+ """逃生舱 —— 直接发动作数组,CLI 没做成子命令的动作都能用它。"""
391
+ actions = json.loads(args.actions)
392
+ if not isinstance(actions, list):
393
+ actions = [actions]
394
+ t = _tab(args)
395
+ r = t.act(actions, note=args.note, user=args.user)
396
+ _out(args, {"results": r.results, "log_from": r.log_from})
397
+ bad = r.failed
398
+ if bad:
399
+ raise WebmuxdError(bad.get("message", ""), code=bad.get("error"),
400
+ details={"candidates": bad.get("candidates") or []})
401
+ if not args.json:
402
+ print(f"✓ {len(r.results)} 个动作")
403
+ return 0
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # 看
408
+ # ---------------------------------------------------------------------------
409
+
410
+ def cmd_url(args: argparse.Namespace) -> int:
411
+ t = _tab(args)
412
+ _out(args, t._row(), t.url)
413
+ return 0
414
+
415
+
416
+ def cmd_status(args: argparse.Namespace) -> int:
417
+ st = _session(args).status()
418
+ _out(args, st,
419
+ f"chrome {'活着' if st['chrome']['alive'] else '没了'} "
420
+ f"{st['tab_count']} 个 tab 当前 {st['active_tab']} "
421
+ f"{'忙' if st['busy'] else '闲'}")
422
+ return 0
423
+
424
+
425
+ def cmd_capture(args: argparse.Namespace) -> int:
426
+ t = _tab(args)
427
+ if args.shot:
428
+ t.screenshot(args.shot)
429
+ print(f"✓ 存到 {args.shot}")
430
+ else:
431
+ sys.stdout.write(t.text())
432
+ return 0
433
+
434
+
435
+ def cmd_observe(args: argparse.Namespace) -> int:
436
+ t = _tab(args)
437
+ obs = t.observe()
438
+ if args.shot:
439
+ with open(args.shot, "wb") as fh:
440
+ fh.write(obs.screenshot)
441
+ _out(args, obs._d, obs.as_prompt())
442
+ if not args.json:
443
+ for n in obs.notes:
444
+ print(f" ⚠ {n}", file=sys.stderr)
445
+ return 0
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # 日志
450
+ # ---------------------------------------------------------------------------
451
+
452
+ def cmd_log(args: argparse.Namespace) -> int:
453
+ sess = _session(args)
454
+ _sid, key = _split_target(args.target)
455
+ tab_id = _tab(args).id if key else None
456
+ entries = sess.log(limit=args.n, tab=tab_id, kind=args.kind,
457
+ user=args.log_user,
458
+ only="failed" if args.failed else None)
459
+ _out(args, {"entries": entries})
460
+ if args.json:
461
+ return 0
462
+ for e in entries:
463
+ at = (e.get("at") or "")[11:19]
464
+ if e.get("note"):
465
+ print(f"{at} 💭 {e.get('user','')}:{e['note']}")
466
+ if e.get("kind") != "action":
467
+ print(f"{at} · {e.get('kind')}: {e.get('event','')} {e.get('tab','')}")
468
+ continue
469
+ mark = "✗" if e.get("ok") is False else ("👤" if e.get("user") == "human" else " ")
470
+ hit = (e.get("hit") or {}).get("name")
471
+ print(f"{at} {mark} {e.get('action')} {json.dumps(e.get('target'), ensure_ascii=False)}"
472
+ + (f" → {hit}" if hit else "")
473
+ + (f" {e.get('error')}" if e.get("error") else ""))
474
+ after = e.get("after") or {}
475
+ if after.get("changed"):
476
+ print(f" → {after.get('url','')} {after['changed']}")
477
+ return 0
478
+
479
+
480
+ def cmd_bundle(args: argparse.Namespace) -> int:
481
+ data = _session(args).bundle(args.out)
482
+ print(f"✓ {len(data)} 字节 → {args.out}")
483
+ return 0
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+
488
+ def _parser() -> argparse.ArgumentParser:
489
+ p = argparse.ArgumentParser(prog="webmuxd", description="tmux + ttyd,给浏览器")
490
+ p.add_argument("-H", "--host", default=os.environ.get("WEBMUXD_HOST"),
491
+ help="远端 server")
492
+ p.add_argument("-L", "--socket-name", default="default", help="换一套独立的登记簿")
493
+ p.add_argument("--json", action="store_true", help="吐 API 的原始响应")
494
+ p.add_argument("--user", default=None, help="操作的署名,进日志")
495
+ p.add_argument("--note", default=None, help="这一步的思考,进日志")
496
+ sub = p.add_subparsers(dest="cmd")
497
+
498
+ def add(name: str, fn, target: bool = True, **kw):
499
+ s = sub.add_parser(name, **kw)
500
+ if target:
501
+ s.add_argument("-t", "--target", default=None,
502
+ help="session[:tab],tab 可以是 index / t_N / 标题")
503
+ s.set_defaults(fn=fn)
504
+ return s
505
+
506
+ n = add("new", cmd_new, target=False, help="起一个 session")
507
+ n.add_argument("-s", "--id", required=True)
508
+ n.add_argument("-p", "--port", type=int, required=True, help="API 口,必填")
509
+ n.add_argument("--vnc-port", type=int, default=0, help="画面口")
510
+ n.add_argument("--runtime", default=None,
511
+ choices=["container", "process", "remote"])
512
+ n.add_argument("-u", "--url", default=None)
513
+ n.add_argument("-v", "--viewport", default=None)
514
+ n.add_argument("--volume", default=None)
515
+ n.add_argument("--proxy", default=None)
516
+ n.add_argument("--endpoint", default=None)
517
+ n.add_argument("-d", action="store_true", help="建完不 attach(默认就是)")
518
+
519
+ ins = add("install", cmd_install, target=False,
520
+ help="探一遍环境、装该装的、把结果记下来")
521
+ ins.add_argument("--no-pull", action="store_true",
522
+ help="不拉镜像,只探(离线时用)")
523
+ add("ls", cmd_ls, target=False, help="列出 session")
524
+ add("info", cmd_info, target=False, help="server 状态和 runtime 探测")
525
+ add("has", cmd_has, help="只返回退出码,给脚本用")
526
+ add("kill", cmd_kill, help="停掉一个 session")
527
+ a = add("attach", cmd_attach, help="打开画面")
528
+ a.add_argument("-p", "--print-only", action="store_true")
529
+
530
+ t = add("tabs", cmd_tabs, help="列出 tab")
531
+ t.add_argument("-F", "--format", default=None, help="#{tab_id} #{tab_url} …")
532
+ nt = add("new-tab", cmd_new_tab, help="新建 tab")
533
+ nt.add_argument("-u", "--url", default=None)
534
+ nt.add_argument("-n", "--no-switch", action="store_true")
535
+ add("select-tab", cmd_select_tab, help="切过去")
536
+ add("kill-tab", cmd_kill_tab, help="关掉")
537
+ g = add("goto", _nav("goto"), help="导航")
538
+ g.add_argument("url")
539
+ for verb in ("back", "forward", "reload", "stop"):
540
+ add(verb, _nav(verb), help=verb)
541
+ d = add("dialog", cmd_dialog, help="回应 alert / confirm / prompt")
542
+ d.add_argument("--dismiss", action="store_true")
543
+ d.add_argument("--text", default=None)
544
+
545
+ for name, fn in (("click", cmd_click),):
546
+ c = add(name, fn, help="点一下")
547
+ c.add_argument("what", nargs="?", default=None, help="可见文字")
548
+ c.add_argument("--role"), c.add_argument("--name")
549
+ c.add_argument("--css"), c.add_argument("--at")
550
+ c.add_argument("--nth", type=int, default=None)
551
+ ty = add("type", cmd_type, help="输入")
552
+ ty.add_argument("what", nargs="?", default=None, help="标签")
553
+ ty.add_argument("text")
554
+ ty.add_argument("--label"), ty.add_argument("--css")
555
+ ty.add_argument("--role"), ty.add_argument("--name")
556
+ ty.add_argument("--nth", type=int, default=None), ty.add_argument("--at")
557
+ k = add("key", cmd_key, help="按键")
558
+ k.add_argument("key")
559
+ sc = add("scroll", cmd_scroll, help="滚动")
560
+ sc.add_argument("--dy", type=float, default=400)
561
+ w = add("wait", cmd_wait, help="等条件")
562
+ w.add_argument("--text"), w.add_argument("--css")
563
+ w.add_argument("--url-contains", dest="url_contains")
564
+ w.add_argument("--timeout", type=float, default=10)
565
+ se = add("send", cmd_send, help="逃生舱:原始动作数组")
566
+ se.add_argument("actions")
567
+
568
+ add("url", cmd_url, help="当前 URL")
569
+ add("status", cmd_status, help="session 状态")
570
+ cap = add("capture", cmd_capture, help="抓正文或截图")
571
+ cap.add_argument("--text", action="store_true")
572
+ cap.add_argument("--shot", default=None)
573
+ ob = add("observe", cmd_observe, help="元素表")
574
+ ob.add_argument("--shot", default=None)
575
+
576
+ lg = add("log", cmd_log, help="操作日志")
577
+ lg.add_argument("-n", type=int, default=50)
578
+ lg.add_argument("--failed", action="store_true")
579
+ lg.add_argument("--kind", default=None, choices=["action", "tab", "session"])
580
+ lg.add_argument("--user", dest="log_user", default=None)
581
+ b = add("bundle", cmd_bundle, help="打包日志和截图")
582
+ b.add_argument("-o", "--out", default="bundle.zip")
583
+ return p
584
+
585
+
586
+ if __name__ == "__main__":
587
+ sys.exit(main())