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.
@@ -0,0 +1,3 @@
1
+ """Silicon Browser — terminal-native browser CLI for AI agents, powered by Steel."""
2
+
3
+ __version__ = "0.1.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`")