silicon-browser 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.
- silicon_browser/__init__.py +3 -0
- silicon_browser/browser.py +92 -0
- silicon_browser/cli.py +243 -0
- silicon_browser/commands/__init__.py +1 -0
- silicon_browser/commands/_common.py +31 -0
- silicon_browser/commands/inspect.py +88 -0
- silicon_browser/commands/interact.py +67 -0
- silicon_browser/commands/manage.py +80 -0
- silicon_browser/commands/navigate.py +55 -0
- silicon_browser/commands/share.py +83 -0
- silicon_browser/commands/tabs.py +46 -0
- silicon_browser/config.py +72 -0
- silicon_browser/output.py +46 -0
- silicon_browser/profiles.py +84 -0
- silicon_browser/py.typed +0 -0
- silicon_browser/refs.py +160 -0
- silicon_browser/session.py +150 -0
- silicon_browser/state.py +71 -0
- silicon_browser/steel_client.py +19 -0
- silicon_browser-0.1.0.dist-info/METADATA +157 -0
- silicon_browser-0.1.0.dist-info/RECORD +24 -0
- silicon_browser-0.1.0.dist-info/WHEEL +4 -0
- silicon_browser-0.1.0.dist-info/entry_points.txt +3 -0
- silicon_browser-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Playwright connection over Steel's remote CDP endpoint.
|
|
2
|
+
|
|
3
|
+
Every command opens a short-lived CDP connection to the (already running) Steel
|
|
4
|
+
browser, does its work, persists any state changes, and disconnects. Closing the
|
|
5
|
+
Playwright connection does **not** end the Steel session — that only happens on
|
|
6
|
+
an explicit ``close``/``release`` or when the session timeout elapses.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Iterator
|
|
14
|
+
|
|
15
|
+
from playwright.sync_api import Browser, BrowserContext, Page, sync_playwright
|
|
16
|
+
|
|
17
|
+
from .config import get_api_key, get_connect_url
|
|
18
|
+
from .state import SessionState
|
|
19
|
+
|
|
20
|
+
NAV_TIMEOUT_MS = 30_000
|
|
21
|
+
ACTION_TIMEOUT_MS = 15_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BrowserError(Exception):
|
|
25
|
+
"""Raised when the remote browser cannot be reached or driven."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Connection:
|
|
30
|
+
browser: Browser
|
|
31
|
+
context: BrowserContext
|
|
32
|
+
page: Page
|
|
33
|
+
state: SessionState
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cdp_url(state: SessionState) -> str:
|
|
37
|
+
key = get_api_key()
|
|
38
|
+
base = state.websocket_url
|
|
39
|
+
override = get_connect_url()
|
|
40
|
+
if override:
|
|
41
|
+
# Replace just the scheme+host for self-hosted Steel, keep the query.
|
|
42
|
+
from urllib.parse import urlparse, urlunparse
|
|
43
|
+
|
|
44
|
+
ws = urlparse(base)
|
|
45
|
+
ov = urlparse(override)
|
|
46
|
+
base = urlunparse((ov.scheme or ws.scheme, ov.netloc, ws.path,
|
|
47
|
+
ws.params, ws.query, ws.fragment))
|
|
48
|
+
sep = "&" if "?" in base else "?"
|
|
49
|
+
return f"{base}{sep}apiKey={key}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _active_page(context: BrowserContext, state: SessionState) -> Page:
|
|
53
|
+
pages = context.pages
|
|
54
|
+
if not pages:
|
|
55
|
+
return context.new_page()
|
|
56
|
+
idx = state.active_tab
|
|
57
|
+
if idx < 0 or idx >= len(pages):
|
|
58
|
+
idx = len(pages) - 1
|
|
59
|
+
state.active_tab = idx
|
|
60
|
+
return pages[idx]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def connect(state: SessionState) -> Iterator[Connection]:
|
|
65
|
+
"""Connect to the session's remote browser and yield the active page.
|
|
66
|
+
|
|
67
|
+
State is saved automatically on clean exit.
|
|
68
|
+
"""
|
|
69
|
+
with sync_playwright() as p:
|
|
70
|
+
try:
|
|
71
|
+
browser = p.chromium.connect_over_cdp(_cdp_url(state))
|
|
72
|
+
except Exception as exc: # noqa: BLE001
|
|
73
|
+
raise BrowserError(
|
|
74
|
+
f"Could not connect to the Steel session ({state.session_id}). "
|
|
75
|
+
"It may have expired — run `silicon-browser open <url>` to start a "
|
|
76
|
+
f"new one.\n underlying error: {exc}"
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
contexts = browser.contexts
|
|
80
|
+
context = contexts[0] if contexts else browser.new_context()
|
|
81
|
+
# Fail loudly rather than hang forever on a stuck navigation/action.
|
|
82
|
+
context.set_default_navigation_timeout(NAV_TIMEOUT_MS)
|
|
83
|
+
context.set_default_timeout(ACTION_TIMEOUT_MS)
|
|
84
|
+
page = _active_page(context, state)
|
|
85
|
+
try:
|
|
86
|
+
yield Connection(browser=browser, context=context, page=page, state=state)
|
|
87
|
+
state.save()
|
|
88
|
+
finally:
|
|
89
|
+
try:
|
|
90
|
+
browser.close()
|
|
91
|
+
except Exception: # noqa: BLE001
|
|
92
|
+
pass
|
silicon_browser/cli.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Silicon Browser command-line interface (Typer)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import ConfigError, Options
|
|
11
|
+
from .output import fail
|
|
12
|
+
from .commands import inspect as inspect_cmds
|
|
13
|
+
from .commands import interact as interact_cmds
|
|
14
|
+
from .commands import manage as manage_cmds
|
|
15
|
+
from .commands import navigate as navigate_cmds
|
|
16
|
+
from .commands import share as share_cmd
|
|
17
|
+
from .commands import tabs as tab_cmds
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="silicon-browser",
|
|
21
|
+
help="Terminal-native browser for AI agents, powered by Steel Browser.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=False,
|
|
24
|
+
)
|
|
25
|
+
get_app = typer.Typer(help="Extract data from an element by @ref.", no_args_is_help=True)
|
|
26
|
+
tab_app = typer.Typer(help="Manage browser tabs.", no_args_is_help=True)
|
|
27
|
+
profile_app = typer.Typer(help="Manage persistent profiles.", no_args_is_help=True)
|
|
28
|
+
app.add_typer(get_app, name="get")
|
|
29
|
+
app.add_typer(tab_app, name="tab")
|
|
30
|
+
app.add_typer(profile_app, name="profile")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _opts(ctx: typer.Context) -> Options:
|
|
34
|
+
return ctx.obj # set in the root callback
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version(value: bool) -> None:
|
|
38
|
+
if value:
|
|
39
|
+
typer.echo(f"silicon-browser {__version__}")
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback()
|
|
44
|
+
def main(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
session: str = typer.Option("default", "--session", "-s", help="Named session."),
|
|
47
|
+
profile: Optional[str] = typer.Option(None, "--profile", help="Persistent profile."),
|
|
48
|
+
incognito: bool = typer.Option(False, "--incognito", help="Ephemeral session."),
|
|
49
|
+
proxy: Optional[str] = typer.Option(None, "--proxy", help="Proxy URL."),
|
|
50
|
+
user_agent: Optional[str] = typer.Option(None, "--user-agent", help="Custom UA."),
|
|
51
|
+
solve_captcha: bool = typer.Option(False, "--solve-captcha", help="Auto-solve CAPTCHAs."),
|
|
52
|
+
_v: bool = typer.Option(False, "--version", callback=_version, is_eager=True),
|
|
53
|
+
) -> None:
|
|
54
|
+
ctx.obj = Options(
|
|
55
|
+
session=session,
|
|
56
|
+
profile=profile,
|
|
57
|
+
incognito=incognito,
|
|
58
|
+
proxy=proxy,
|
|
59
|
+
user_agent=user_agent,
|
|
60
|
+
solve_captcha=solve_captcha,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --- navigation / lifecycle ------------------------------------------------
|
|
65
|
+
@app.command("open")
|
|
66
|
+
def open_(ctx: typer.Context, url: str = typer.Argument(..., help="URL to open.")):
|
|
67
|
+
"""Navigate to a URL (starts a session if none is active)."""
|
|
68
|
+
navigate_cmds.cmd_open(_opts(ctx), url)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("close")
|
|
72
|
+
def close(ctx: typer.Context):
|
|
73
|
+
"""Close the session and release the Steel browser."""
|
|
74
|
+
navigate_cmds.cmd_close(_opts(ctx))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("install")
|
|
78
|
+
def install(ctx: typer.Context):
|
|
79
|
+
"""Verify Steel credentials and connectivity (no local Chrome needed)."""
|
|
80
|
+
navigate_cmds.cmd_install(_opts(ctx))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- inspection ------------------------------------------------------------
|
|
84
|
+
@app.command("snapshot")
|
|
85
|
+
def snapshot(
|
|
86
|
+
ctx: typer.Context,
|
|
87
|
+
interactive: bool = typer.Option(False, "-i", "--interactive", help="Interactive elements only."),
|
|
88
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
89
|
+
):
|
|
90
|
+
"""Capture the accessibility tree with @refs."""
|
|
91
|
+
inspect_cmds.cmd_snapshot(_opts(ctx), interactive, json_out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command("screenshot")
|
|
95
|
+
def screenshot(
|
|
96
|
+
ctx: typer.Context,
|
|
97
|
+
path: Optional[str] = typer.Argument(None, help="Output path (default: auto)."),
|
|
98
|
+
full_page: bool = typer.Option(False, "--full-page", help="Capture full page."),
|
|
99
|
+
):
|
|
100
|
+
"""Capture a screenshot."""
|
|
101
|
+
inspect_cmds.cmd_screenshot(_opts(ctx), path, full_page)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("evaluate")
|
|
105
|
+
def evaluate(ctx: typer.Context, expression: str = typer.Argument(..., help="JS expression.")):
|
|
106
|
+
"""Execute JavaScript in the page and print the result."""
|
|
107
|
+
inspect_cmds.cmd_evaluate(_opts(ctx), expression)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@get_app.command("text")
|
|
111
|
+
def get_text(ctx: typer.Context, ref: str):
|
|
112
|
+
"""Get an element's text."""
|
|
113
|
+
inspect_cmds.cmd_get_text(_opts(ctx), ref)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@get_app.command("html")
|
|
117
|
+
def get_html(ctx: typer.Context, ref: str):
|
|
118
|
+
"""Get an element's inner HTML."""
|
|
119
|
+
inspect_cmds.cmd_get_html(_opts(ctx), ref)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@get_app.command("value")
|
|
123
|
+
def get_value(ctx: typer.Context, ref: str):
|
|
124
|
+
"""Get an input element's value."""
|
|
125
|
+
inspect_cmds.cmd_get_value(_opts(ctx), ref)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- interaction -----------------------------------------------------------
|
|
129
|
+
@app.command("click")
|
|
130
|
+
def click(ctx: typer.Context, ref: str):
|
|
131
|
+
"""Click an element by @ref."""
|
|
132
|
+
interact_cmds.cmd_click(_opts(ctx), ref)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command("fill")
|
|
136
|
+
def fill(ctx: typer.Context, ref: str, text: str):
|
|
137
|
+
"""Fill an input by @ref."""
|
|
138
|
+
interact_cmds.cmd_fill(_opts(ctx), ref, text)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command("type")
|
|
142
|
+
def type_(
|
|
143
|
+
ctx: typer.Context,
|
|
144
|
+
text: str,
|
|
145
|
+
delay: float = typer.Option(0.0, "--delay", help="Per-keystroke delay (ms)."),
|
|
146
|
+
):
|
|
147
|
+
"""Type text at the keyboard level."""
|
|
148
|
+
interact_cmds.cmd_type(_opts(ctx), text, delay)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command("select")
|
|
152
|
+
def select(ctx: typer.Context, ref: str, value: str):
|
|
153
|
+
"""Select a dropdown option by @ref."""
|
|
154
|
+
interact_cmds.cmd_select(_opts(ctx), ref, value)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command("hover")
|
|
158
|
+
def hover(ctx: typer.Context, ref: str):
|
|
159
|
+
"""Hover over an element by @ref."""
|
|
160
|
+
interact_cmds.cmd_hover(_opts(ctx), ref)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command("scroll")
|
|
164
|
+
def scroll(
|
|
165
|
+
ctx: typer.Context,
|
|
166
|
+
direction: str = typer.Argument("down", help="down | up | top | bottom."),
|
|
167
|
+
amount: Optional[int] = typer.Option(None, "--amount", help="Pixels for up/down."),
|
|
168
|
+
):
|
|
169
|
+
"""Scroll the page."""
|
|
170
|
+
interact_cmds.cmd_scroll(_opts(ctx), direction, amount)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- tabs ------------------------------------------------------------------
|
|
174
|
+
@app.command("tabs")
|
|
175
|
+
def tabs(ctx: typer.Context):
|
|
176
|
+
"""List open tabs."""
|
|
177
|
+
tab_cmds.cmd_tabs(_opts(ctx))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@tab_app.command("new")
|
|
181
|
+
def tab_new(ctx: typer.Context, url: Optional[str] = typer.Argument(None)):
|
|
182
|
+
"""Open a new tab (optionally at a URL)."""
|
|
183
|
+
tab_cmds.cmd_tab_new(_opts(ctx), url)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@tab_app.command("select")
|
|
187
|
+
def tab_select(ctx: typer.Context, index: int):
|
|
188
|
+
"""Switch to a tab by index."""
|
|
189
|
+
tab_cmds.cmd_tab_select(_opts(ctx), index)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- remote access link ----------------------------------------------------
|
|
193
|
+
@app.command("share")
|
|
194
|
+
def share(
|
|
195
|
+
ctx: typer.Context,
|
|
196
|
+
expiry: Optional[int] = typer.Option(None, "--expiry", "-e", help="Minutes (default 60)."),
|
|
197
|
+
view_only: bool = typer.Option(False, "--view-only", help="Disable take-control."),
|
|
198
|
+
new: bool = typer.Option(False, "--new", help="Force a fresh session for this link."),
|
|
199
|
+
):
|
|
200
|
+
"""Generate a remote browser access link with an expiry (default 60 min)."""
|
|
201
|
+
share_cmd.cmd_share(_opts(ctx), expiry, view_only, new)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- management ------------------------------------------------------------
|
|
205
|
+
@app.command("status")
|
|
206
|
+
def status(ctx: typer.Context):
|
|
207
|
+
"""Show the current session's status and expiry."""
|
|
208
|
+
manage_cmds.cmd_status(_opts(ctx))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command("sessions")
|
|
212
|
+
def sessions(ctx: typer.Context):
|
|
213
|
+
"""List local sessions and whether they're still live."""
|
|
214
|
+
manage_cmds.cmd_sessions_list(_opts(ctx))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@profile_app.command("list")
|
|
218
|
+
def profile_list(ctx: typer.Context):
|
|
219
|
+
"""List saved profiles."""
|
|
220
|
+
manage_cmds.cmd_profile_list(_opts(ctx))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@profile_app.command("save")
|
|
224
|
+
def profile_save(ctx: typer.Context, name: str):
|
|
225
|
+
"""Save the current session's context into a profile."""
|
|
226
|
+
manage_cmds.cmd_profile_save(_opts(ctx), name)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@profile_app.command("delete")
|
|
230
|
+
def profile_delete(ctx: typer.Context, name: str):
|
|
231
|
+
"""Delete a saved profile."""
|
|
232
|
+
manage_cmds.cmd_profile_delete(_opts(ctx), name)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run() -> None:
|
|
236
|
+
try:
|
|
237
|
+
app()
|
|
238
|
+
except ConfigError as exc:
|
|
239
|
+
fail(str(exc))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command implementations for the Silicon Browser CLI."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Helpers shared across command implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
|
|
8
|
+
from ..browser import Connection, connect
|
|
9
|
+
from ..config import Options
|
|
10
|
+
from ..output import fail
|
|
11
|
+
from ..session import ensure_session, get_live_state
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@contextmanager
|
|
15
|
+
def page_for(opts: Options, *, create: bool = False) -> Iterator[Connection]:
|
|
16
|
+
"""Yield a live :class:`Connection` for the requested session.
|
|
17
|
+
|
|
18
|
+
With ``create=True`` a session is started if none exists (used by ``open``).
|
|
19
|
+
Otherwise a missing/expired session is a hard error with guidance.
|
|
20
|
+
"""
|
|
21
|
+
if create:
|
|
22
|
+
state = ensure_session(opts)
|
|
23
|
+
else:
|
|
24
|
+
state = get_live_state(opts.session)
|
|
25
|
+
if state is None:
|
|
26
|
+
fail(
|
|
27
|
+
f"No active session named '{opts.session}'. "
|
|
28
|
+
"Start one with `silicon-browser open <url>`."
|
|
29
|
+
)
|
|
30
|
+
with connect(state) as conn:
|
|
31
|
+
yield conn
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Inspection commands: snapshot, get text/html/value, screenshot, evaluate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..config import SCREENSHOTS_DIR, Options, ensure_dirs
|
|
10
|
+
from ..output import console, fail, info, render_snapshot, success
|
|
11
|
+
from ..refs import RefError, resolve, take_snapshot
|
|
12
|
+
from ._common import page_for
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cmd_snapshot(opts: Options, interactive_only: bool, as_json: bool) -> None:
|
|
16
|
+
with page_for(opts) as conn:
|
|
17
|
+
nodes = take_snapshot(conn.page, conn.state, interactive_only)
|
|
18
|
+
if as_json:
|
|
19
|
+
console.print_json(json.dumps(nodes))
|
|
20
|
+
else:
|
|
21
|
+
render_snapshot(nodes)
|
|
22
|
+
info(f"\n[dim]{len(nodes)} element(s). Use @ref with click/fill/get.[/dim]")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_one(opts: Options, kind: str, ref: str) -> None:
|
|
26
|
+
with page_for(opts) as conn:
|
|
27
|
+
try:
|
|
28
|
+
loc = resolve(conn.page, conn.state, ref)
|
|
29
|
+
except RefError as exc:
|
|
30
|
+
fail(str(exc))
|
|
31
|
+
return
|
|
32
|
+
if kind == "text":
|
|
33
|
+
out = loc.inner_text()
|
|
34
|
+
elif kind == "html":
|
|
35
|
+
out = loc.inner_html()
|
|
36
|
+
elif kind == "value":
|
|
37
|
+
out = loc.input_value()
|
|
38
|
+
else: # pragma: no cover - guarded by CLI
|
|
39
|
+
fail(f"Unknown get target '{kind}'.")
|
|
40
|
+
return
|
|
41
|
+
console.print(out)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_get_text(opts: Options, ref: str) -> None:
|
|
45
|
+
_get_one(opts, "text", ref)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_get_html(opts: Options, ref: str) -> None:
|
|
49
|
+
_get_one(opts, "html", ref)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_get_value(opts: Options, ref: str) -> None:
|
|
53
|
+
_get_one(opts, "value", ref)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_screenshot(opts: Options, path: str | None, full_page: bool) -> None:
|
|
57
|
+
ensure_dirs()
|
|
58
|
+
if path:
|
|
59
|
+
out_path = Path(path).expanduser()
|
|
60
|
+
else:
|
|
61
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
62
|
+
out_path = SCREENSHOTS_DIR / f"{opts.session}-{stamp}.png"
|
|
63
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
with page_for(opts) as conn:
|
|
65
|
+
conn.page.screenshot(path=str(out_path), full_page=full_page)
|
|
66
|
+
success(f"Saved screenshot → [bold]{out_path}[/bold]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _evaluate(page, expression: str):
|
|
70
|
+
"""Evaluate JS, falling back to a function body for multi-statement code."""
|
|
71
|
+
try:
|
|
72
|
+
return page.evaluate(expression)
|
|
73
|
+
except Exception as exc: # noqa: BLE001
|
|
74
|
+
msg = str(exc)
|
|
75
|
+
if "SyntaxError" in msg or "Illegal return" in msg or "Unexpected" in msg:
|
|
76
|
+
return page.evaluate(f"() => {{ {expression} }}")
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_evaluate(opts: Options, expression: str) -> None:
|
|
81
|
+
with page_for(opts) as conn:
|
|
82
|
+
result = _evaluate(conn.page, expression)
|
|
83
|
+
if result is None:
|
|
84
|
+
info("[dim]undefined[/dim]")
|
|
85
|
+
elif isinstance(result, (dict, list)):
|
|
86
|
+
console.print_json(json.dumps(result, default=str))
|
|
87
|
+
else:
|
|
88
|
+
console.print(str(result))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Interaction commands: click, fill, type, select, hover, scroll."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..config import Options
|
|
6
|
+
from ..output import fail, success
|
|
7
|
+
from ..refs import RefError, resolve
|
|
8
|
+
from ._common import page_for
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _resolve_or_fail(conn, ref: str):
|
|
12
|
+
try:
|
|
13
|
+
return resolve(conn.page, conn.state, ref)
|
|
14
|
+
except RefError as exc:
|
|
15
|
+
fail(str(exc))
|
|
16
|
+
raise SystemExit(1)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_click(opts: Options, ref: str) -> None:
|
|
20
|
+
with page_for(opts) as conn:
|
|
21
|
+
_resolve_or_fail(conn, ref).click()
|
|
22
|
+
success(f"Clicked @{ref.lstrip('@')}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_fill(opts: Options, ref: str, text: str) -> None:
|
|
26
|
+
with page_for(opts) as conn:
|
|
27
|
+
_resolve_or_fail(conn, ref).fill(text)
|
|
28
|
+
success(f"Filled @{ref.lstrip('@')}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_type(opts: Options, text: str, delay: float) -> None:
|
|
32
|
+
with page_for(opts) as conn:
|
|
33
|
+
conn.page.keyboard.type(text, delay=delay)
|
|
34
|
+
success(f"Typed {len(text)} character(s)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_select(opts: Options, ref: str, value: str) -> None:
|
|
38
|
+
with page_for(opts) as conn:
|
|
39
|
+
_resolve_or_fail(conn, ref).select_option(value)
|
|
40
|
+
success(f"Selected '{value}' on @{ref.lstrip('@')}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_hover(opts: Options, ref: str) -> None:
|
|
44
|
+
with page_for(opts) as conn:
|
|
45
|
+
_resolve_or_fail(conn, ref).hover()
|
|
46
|
+
success(f"Hovered @{ref.lstrip('@')}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_AMOUNTS = {"down": 600, "up": -600, "top": None, "bottom": None}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_scroll(opts: Options, direction: str, amount: int | None) -> None:
|
|
53
|
+
direction = direction.lower()
|
|
54
|
+
with page_for(opts) as conn:
|
|
55
|
+
if direction == "top":
|
|
56
|
+
conn.page.evaluate("window.scrollTo(0, 0)")
|
|
57
|
+
elif direction == "bottom":
|
|
58
|
+
conn.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
59
|
+
elif direction in ("down", "up"):
|
|
60
|
+
dy = amount if amount is not None else abs(_AMOUNTS[direction])
|
|
61
|
+
if direction == "up":
|
|
62
|
+
dy = -dy
|
|
63
|
+
conn.page.mouse.wheel(0, dy)
|
|
64
|
+
else:
|
|
65
|
+
fail("Scroll direction must be one of: down, up, top, bottom.")
|
|
66
|
+
return
|
|
67
|
+
success(f"Scrolled {direction}")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Auxiliary commands: status, sessions list, profile management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
from ..config import Options
|
|
8
|
+
from ..output import console, fail, info, success, warn
|
|
9
|
+
from ..profiles import delete_profile, list_profiles, normalize_context, save_context
|
|
10
|
+
from ..session import get_live_state
|
|
11
|
+
from ..state import SessionState
|
|
12
|
+
from ..steel_client import get_client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fmt_local(dt: datetime) -> str:
|
|
16
|
+
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_status(opts: Options) -> None:
|
|
20
|
+
state = SessionState.load(opts.session)
|
|
21
|
+
if state is None:
|
|
22
|
+
info(f"No session named '{opts.session}'.")
|
|
23
|
+
return
|
|
24
|
+
info(f"[bold]Session:[/bold] {opts.session} ([dim]{state.session_id}[/dim])")
|
|
25
|
+
if state.profile:
|
|
26
|
+
info(f" profile: {state.profile}")
|
|
27
|
+
try:
|
|
28
|
+
remote = get_client().sessions.retrieve(state.session_id)
|
|
29
|
+
expires_at = remote.created_at + timedelta(milliseconds=remote.timeout)
|
|
30
|
+
info(f" status: {remote.status}")
|
|
31
|
+
info(f" expires: {_fmt_local(expires_at)}")
|
|
32
|
+
info(f" credits: {remote.credits_used}")
|
|
33
|
+
except Exception as exc: # noqa: BLE001
|
|
34
|
+
warn(f"Could not reach Steel for live details: {exc}")
|
|
35
|
+
info(f" viewer: {state.session_viewer_url}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_sessions_list(opts: Options) -> None:
|
|
39
|
+
names = SessionState.list_names()
|
|
40
|
+
if not names:
|
|
41
|
+
info("No local sessions.")
|
|
42
|
+
return
|
|
43
|
+
for name in names:
|
|
44
|
+
st = SessionState.load(name)
|
|
45
|
+
if st is None:
|
|
46
|
+
continue
|
|
47
|
+
live = get_live_state(name) is not None
|
|
48
|
+
dot = "[green]●[/green]" if live else "[red]○[/red]"
|
|
49
|
+
console.print(f"{dot} [bold]{name}[/bold] [dim]{st.session_id}[/dim]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_profile_list(opts: Options) -> None:
|
|
53
|
+
profiles = list_profiles()
|
|
54
|
+
if not profiles:
|
|
55
|
+
info("No saved profiles.")
|
|
56
|
+
return
|
|
57
|
+
for p in profiles:
|
|
58
|
+
console.print(f" {p}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_profile_save(opts: Options, name: str) -> None:
|
|
62
|
+
"""Capture the current session's context into a named profile."""
|
|
63
|
+
state = get_live_state(opts.session)
|
|
64
|
+
if state is None:
|
|
65
|
+
fail(f"No active session named '{opts.session}' to save from.")
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
ctx = get_client().sessions.context(state.session_id)
|
|
69
|
+
except Exception as exc: # noqa: BLE001
|
|
70
|
+
fail(f"Could not fetch session context: {exc}")
|
|
71
|
+
return
|
|
72
|
+
save_context(name, normalize_context(ctx.model_dump(exclude_none=True)))
|
|
73
|
+
success(f"Saved profile '{name}' from session '{opts.session}'.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_profile_delete(opts: Options, name: str) -> None:
|
|
77
|
+
if delete_profile(name):
|
|
78
|
+
success(f"Deleted profile '{name}'.")
|
|
79
|
+
else:
|
|
80
|
+
warn(f"No profile named '{name}'.")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Navigation & lifecycle commands: open, close, install."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..config import Options, get_api_key
|
|
6
|
+
from ..output import fail, info, success, warn
|
|
7
|
+
from ..session import release_session
|
|
8
|
+
from ..steel_client import get_client
|
|
9
|
+
from ._common import page_for
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize_url(url: str) -> str:
|
|
13
|
+
if "://" not in url and not url.startswith("about:"):
|
|
14
|
+
return "https://" + url
|
|
15
|
+
return url
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_open(opts: Options, url: str) -> None:
|
|
19
|
+
target = _normalize_url(url)
|
|
20
|
+
with page_for(opts, create=True) as conn:
|
|
21
|
+
conn.page.goto(target, wait_until="domcontentloaded")
|
|
22
|
+
title = conn.page.title()
|
|
23
|
+
final_url = conn.page.url
|
|
24
|
+
success(f"Opened [bold]{final_url}[/bold]")
|
|
25
|
+
if title:
|
|
26
|
+
info(f" title: {title}")
|
|
27
|
+
info(f" session: {opts.session}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cmd_close(opts: Options) -> None:
|
|
31
|
+
released = release_session(opts.session)
|
|
32
|
+
if released:
|
|
33
|
+
success(f"Closed session '{opts.session}'.")
|
|
34
|
+
else:
|
|
35
|
+
warn(f"No active session named '{opts.session}'.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_install(opts: Options) -> None:
|
|
39
|
+
"""No Chrome to download with Steel — verify connectivity & credentials."""
|
|
40
|
+
try:
|
|
41
|
+
key = get_api_key()
|
|
42
|
+
except Exception as exc: # ConfigError
|
|
43
|
+
fail(str(exc))
|
|
44
|
+
return
|
|
45
|
+
info("Verifying Steel credentials…")
|
|
46
|
+
try:
|
|
47
|
+
client = get_client()
|
|
48
|
+
# A cheap authenticated call to confirm the key works.
|
|
49
|
+
client.sessions.list()
|
|
50
|
+
except Exception as exc: # noqa: BLE001
|
|
51
|
+
fail(f"Could not authenticate with Steel: {exc}")
|
|
52
|
+
return
|
|
53
|
+
success("Steel is reachable and your API key works.")
|
|
54
|
+
info(f" key: …{key[-6:]}")
|
|
55
|
+
info("You're ready: `silicon-browser open example.com`")
|