teardrop-cli 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,207 @@
1
+ """tools commands: list, test."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ app = typer.Typer(
12
+ name="tools",
13
+ help="Org tools — list and test.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # list
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ @app.command(name="list")
24
+ def list_tools(
25
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
26
+ base_url: Annotated[str | None, typer.Option("--base-url", hidden=True)] = None,
27
+ ) -> None:
28
+ """List org tools."""
29
+ from teardrop_cli import config
30
+ from teardrop_cli.formatting import data_console, print_json, print_table, spinner
31
+
32
+ client = config.get_client(base_url)
33
+
34
+ async def _fetch():
35
+ try:
36
+ return await client.list_tools()
37
+ finally:
38
+ await client.close()
39
+
40
+ with spinner("Fetching tools…"):
41
+ tools = asyncio.run(_fetch())
42
+
43
+ if as_json:
44
+ print_json([t.model_dump() for t in tools])
45
+ return
46
+
47
+ if not tools:
48
+ data_console.print("[dim]No tools found.[/dim]")
49
+ return
50
+
51
+ rows = [
52
+ [
53
+ t.id,
54
+ t.name,
55
+ (getattr(t, "description", "") or "")[:60],
56
+ getattr(t, "type", ""),
57
+ ]
58
+ for t in tools
59
+ ]
60
+ print_table(
61
+ ["ID", "Name", "Description", "Type"],
62
+ rows,
63
+ title="Org Tools",
64
+ )
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # test
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ @app.command(name="test")
73
+ def test_tool(
74
+ tool_id: Annotated[str, typer.Argument(help="Tool ID to inspect.")],
75
+ input_json: Annotated[
76
+ str | None,
77
+ typer.Option(
78
+ "--input",
79
+ "-i",
80
+ help="JSON object of input parameters for a dry-run validation.",
81
+ ),
82
+ ] = None,
83
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
84
+ base_url: Annotated[str | None, typer.Option("--base-url", hidden=True)] = None,
85
+ ) -> None:
86
+ """Fetch a tool definition and optionally validate input against its schema."""
87
+ from teardrop_cli import config
88
+ from teardrop_cli.formatting import (
89
+ console,
90
+ data_console,
91
+ print_error,
92
+ print_json,
93
+ print_success,
94
+ print_table,
95
+ spinner,
96
+ )
97
+
98
+ client = config.get_client(base_url)
99
+
100
+ async def _fetch():
101
+ try:
102
+ return await client.get_tool(tool_id)
103
+ finally:
104
+ await client.close()
105
+
106
+ with spinner(f"Fetching tool [bold]{tool_id}[/bold]…"):
107
+ tool = asyncio.run(_fetch())
108
+
109
+ tool_data = tool.model_dump()
110
+
111
+ if as_json:
112
+ print_json(tool_data)
113
+ return
114
+
115
+ # Display tool metadata
116
+ meta_rows = [
117
+ [k, v] for k, v in tool_data.items() if k not in ("parameters", "schema") and v is not None
118
+ ]
119
+ print_table([("Field", {"style": "bold cyan"}), "Value"], meta_rows, title="Tool Definition")
120
+
121
+ # Display parameter schema
122
+ schema = tool_data.get("parameters") or tool_data.get("schema")
123
+ if schema and isinstance(schema, dict):
124
+ props = schema.get("properties", {})
125
+ required = set(schema.get("required", []))
126
+ if props:
127
+ param_rows = [
128
+ [
129
+ name,
130
+ "✓" if name in required else "",
131
+ pdef.get("type", ""),
132
+ pdef.get("description", ""),
133
+ ]
134
+ for name, pdef in props.items()
135
+ ]
136
+ print_table(
137
+ ["Parameter", "Required", "Type", "Description"],
138
+ param_rows,
139
+ title="Parameters",
140
+ )
141
+
142
+ # Optional input validation
143
+ if input_json:
144
+ try:
145
+ input_data = json.loads(input_json)
146
+ except json.JSONDecodeError as exc:
147
+ print_error(f"Invalid JSON input: {exc}")
148
+ raise typer.Exit(1) from None
149
+
150
+ errors = _validate_input(schema, input_data) if schema else []
151
+ if errors:
152
+ print_error("Input validation failed:")
153
+ for err in errors:
154
+ console.print(f" [red]•[/red] {err}")
155
+ raise typer.Exit(1)
156
+
157
+ print_success("Input is valid against the tool schema.")
158
+ data_console.print_json(json.dumps(input_data, default=str))
159
+
160
+
161
+ def _validate_input(schema: dict, data: dict) -> list[str]:
162
+ """Basic JSON Schema validation (required fields + type checks).
163
+
164
+ Returns a list of error messages; empty list means valid.
165
+ Does not require jsonschema — intentionally minimal.
166
+ """
167
+ errors: list[str] = []
168
+ required = schema.get("required", [])
169
+ properties = schema.get("properties", {})
170
+
171
+ for field in required:
172
+ if field not in data:
173
+ errors.append(f"Missing required field: '{field}'")
174
+
175
+ for field, value in data.items():
176
+ if field in properties:
177
+ expected_type = properties[field].get("type")
178
+ if expected_type and not _matches_json_type(value, expected_type):
179
+ errors.append(
180
+ f"Field '{field}' expected type '{expected_type}', "
181
+ f"got '{type(value).__name__}'"
182
+ )
183
+
184
+ return errors
185
+
186
+
187
+ _JSON_TYPE_MAP = {
188
+ "string": str,
189
+ "integer": int,
190
+ "number": (int, float),
191
+ "boolean": bool,
192
+ "array": list,
193
+ "object": dict,
194
+ "null": type(None),
195
+ }
196
+
197
+
198
+ def _matches_json_type(value, json_type: str) -> bool:
199
+ expected = _JSON_TYPE_MAP.get(json_type)
200
+ if expected is None:
201
+ return True # Unknown type → no error
202
+ # JSON Schema: integer is a subtype of number
203
+ if json_type == "number" and isinstance(value, bool):
204
+ return False
205
+ if json_type == "integer" and isinstance(value, bool):
206
+ return False
207
+ return isinstance(value, expected)
teardrop_cli/config.py ADDED
@@ -0,0 +1,240 @@
1
+ """Configuration and credential management for teardrop-cli.
2
+
3
+ Credential resolution order (highest priority first):
4
+ 1. TEARDROP_TOKEN env var (static JWT, for CI/CD)
5
+ 2. TEARDROP_EMAIL + TEARDROP_SECRET env vars
6
+ 3. TEARDROP_CLIENT_ID + TEARDROP_CLIENT_SECRET env vars
7
+ 4. System keyring
8
+ 5. Config file (~/.config/teardrop/config.toml)
9
+
10
+ Config/data directories follow XDG / platformdirs conventions.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import os
17
+ import stat
18
+ import tomllib
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import platformdirs
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Keyring service name
26
+ # ---------------------------------------------------------------------------
27
+ _KEYRING_SERVICE = "teardrop-cli"
28
+ _KEYRING_JWT_KEY = "jwt_token"
29
+ _KEYRING_EMAIL_KEY = "email"
30
+ _KEYRING_SECRET_KEY = "secret"
31
+ _KEYRING_CLIENT_ID_KEY = "client_id"
32
+ _KEYRING_CLIENT_SECRET_KEY = "client_secret"
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Directory helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def get_config_dir() -> Path:
40
+ """Return the platform-appropriate config directory, creating it if needed."""
41
+ path = Path(platformdirs.user_config_dir("teardrop", appauthor=False))
42
+ path.mkdir(parents=True, exist_ok=True)
43
+ return path
44
+
45
+
46
+ def _config_file() -> Path:
47
+ return get_config_dir() / "config.toml"
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Config TOML helpers
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def load_config() -> dict[str, Any]:
56
+ """Load config.toml; return empty dict if it doesn't exist."""
57
+ cfg_path = _config_file()
58
+ if not cfg_path.exists():
59
+ return {}
60
+ try:
61
+ return tomllib.loads(cfg_path.read_text(encoding="utf-8"))
62
+ except Exception:
63
+ return {}
64
+
65
+
66
+ def save_config(data: dict[str, Any]) -> None:
67
+ """Persist *data* to config.toml with owner-only read/write permissions."""
68
+ import tomli_w # lazy import — only needed on writes
69
+
70
+ cfg_path = _config_file()
71
+ cfg_path.write_text(tomli_w.dumps(data), encoding="utf-8")
72
+ # Restrict permissions on POSIX (0o600); Windows ignores this silently.
73
+ with contextlib.suppress(NotImplementedError):
74
+ cfg_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Base URL
79
+ # ---------------------------------------------------------------------------
80
+
81
+ DEFAULT_BASE_URL = "https://api.teardrop.dev"
82
+
83
+
84
+ def get_base_url() -> str:
85
+ """Return the API base URL, respecting env var and config overrides."""
86
+ if url := os.environ.get("TEARDROP_BASE_URL"):
87
+ return url.rstrip("/")
88
+ cfg = load_config()
89
+ if url := cfg.get("api", {}).get("base_url"):
90
+ return url.rstrip("/")
91
+ return DEFAULT_BASE_URL
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Credential storage
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def _keyring_available() -> bool:
100
+ try:
101
+ import keyring # noqa: F401
102
+
103
+ return True
104
+ except ImportError:
105
+ return False
106
+
107
+
108
+ def store_token(token: str) -> None:
109
+ """Persist a JWT token (keyring first, config file fallback)."""
110
+ if _keyring_available():
111
+ import keyring
112
+
113
+ try:
114
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_JWT_KEY, token)
115
+ return
116
+ except Exception:
117
+ # Keyring write failed (e.g. Windows CredWrite 2560-byte limit).
118
+ # Fall through to config file.
119
+ pass
120
+
121
+ cfg = load_config()
122
+ cfg.setdefault("auth", {})["token"] = token
123
+ save_config(cfg)
124
+
125
+
126
+ def store_email_credentials(email: str, secret: str) -> None:
127
+ """Persist email + secret (keyring first, config file fallback).
128
+
129
+ Note: the secret (password) is stored in the keyring only. The config
130
+ file stores the email as plain text but never the secret.
131
+ """
132
+ if _keyring_available():
133
+ import keyring
134
+
135
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_EMAIL_KEY, email)
136
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_SECRET_KEY, secret)
137
+ # Always store the email in the config so we can show it in whoami
138
+ cfg = load_config()
139
+ cfg.setdefault("auth", {})["method"] = "email"
140
+ cfg["auth"]["email"] = email
141
+ save_config(cfg)
142
+
143
+
144
+ def store_client_credentials(client_id: str, client_secret: str) -> None:
145
+ """Persist M2M client credentials."""
146
+ if _keyring_available():
147
+ import keyring
148
+
149
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_CLIENT_ID_KEY, client_id)
150
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_CLIENT_SECRET_KEY, client_secret)
151
+
152
+ cfg = load_config()
153
+ cfg.setdefault("auth", {})["method"] = "client_credentials"
154
+ # Store client_id in config (not secret), mirroring email pattern
155
+ cfg["auth"]["client_id"] = client_id
156
+ save_config(cfg)
157
+
158
+
159
+ def clear_credentials() -> None:
160
+ """Remove all stored credentials from keyring and config file."""
161
+ if _keyring_available():
162
+ import keyring
163
+
164
+ for key in (
165
+ _KEYRING_JWT_KEY,
166
+ _KEYRING_EMAIL_KEY,
167
+ _KEYRING_SECRET_KEY,
168
+ _KEYRING_CLIENT_ID_KEY,
169
+ _KEYRING_CLIENT_SECRET_KEY,
170
+ ):
171
+ with contextlib.suppress(Exception):
172
+ keyring.delete_password(_KEYRING_SERVICE, key)
173
+
174
+ cfg = load_config()
175
+ cfg.pop("auth", None)
176
+ save_config(cfg)
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Client factory
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ def get_client(base_url: str | None = None):
185
+ """Build and return an ``AsyncTeardropClient`` from stored credentials.
186
+
187
+ Raises ``SystemExit`` with a friendly message if no credentials are found.
188
+ """
189
+ from teardrop import AsyncTeardropClient
190
+
191
+ from teardrop_cli.formatting import print_error
192
+
193
+ url = base_url or get_base_url()
194
+
195
+ # 1. Static token env var
196
+ if token := os.environ.get("TEARDROP_TOKEN"):
197
+ return AsyncTeardropClient(url, token=token)
198
+
199
+ # 2. Email + secret env vars
200
+ email_env = os.environ.get("TEARDROP_EMAIL")
201
+ secret_env = os.environ.get("TEARDROP_SECRET")
202
+ if email_env and secret_env:
203
+ return AsyncTeardropClient(url, email=email_env, secret=secret_env)
204
+
205
+ # 3. Client credentials env vars
206
+ client_id_env = os.environ.get("TEARDROP_CLIENT_ID")
207
+ client_secret_env = os.environ.get("TEARDROP_CLIENT_SECRET")
208
+ if client_id_env and client_secret_env:
209
+ return AsyncTeardropClient(url, client_id=client_id_env, client_secret=client_secret_env)
210
+
211
+ # 4 & 5. Keyring / config file
212
+ if _keyring_available():
213
+ import keyring
214
+
215
+ # Static JWT stored after login
216
+ if token := keyring.get_password(_KEYRING_SERVICE, _KEYRING_JWT_KEY):
217
+ return AsyncTeardropClient(url, token=token)
218
+
219
+ # Email credentials
220
+ email = keyring.get_password(_KEYRING_SERVICE, _KEYRING_EMAIL_KEY)
221
+ secret = keyring.get_password(_KEYRING_SERVICE, _KEYRING_SECRET_KEY)
222
+ if email and secret:
223
+ return AsyncTeardropClient(url, email=email, secret=secret)
224
+
225
+ # Client credentials
226
+ cid = keyring.get_password(_KEYRING_SERVICE, _KEYRING_CLIENT_ID_KEY)
227
+ csecret = keyring.get_password(_KEYRING_SERVICE, _KEYRING_CLIENT_SECRET_KEY)
228
+ if cid and csecret:
229
+ return AsyncTeardropClient(url, client_id=cid, client_secret=csecret)
230
+
231
+ # Config file fallback (static token only)
232
+ cfg = load_config()
233
+ if token := cfg.get("auth", {}).get("token"):
234
+ return AsyncTeardropClient(url, token=token)
235
+
236
+ print_error(
237
+ "Not authenticated.",
238
+ hint="Run [bold]teardrop auth login[/bold] to sign in.",
239
+ )
240
+ raise SystemExit(1)
@@ -0,0 +1,243 @@
1
+ """Rich formatting utilities for teardrop-cli.
2
+
3
+ All decorative output is sent to **stderr** so that stdout remains clean
4
+ for piping and ``--json`` output. Data output (tables, JSON) is written
5
+ to stdout via a separate ``data_console``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from collections.abc import AsyncIterator, Iterator
13
+ from contextlib import contextmanager
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from rich.console import Console
17
+ from rich.live import Live
18
+ from rich.markdown import Markdown
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+ if TYPE_CHECKING:
23
+ pass
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Consoles
27
+ # ---------------------------------------------------------------------------
28
+
29
+ # Decorative output (spinners, status, errors) → stderr
30
+ console = Console(stderr=True, highlight=False)
31
+
32
+ # Data output (tables, JSON) → stdout so it can be piped / redirected
33
+ data_console = Console(highlight=False)
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Error helpers
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ def print_error(message: str, *, hint: str | None = None) -> None:
41
+ """Print a styled error to stderr."""
42
+ console.print(f"[bold red]Error:[/bold red] {message}")
43
+ if hint:
44
+ console.print(f"[dim]{hint}[/dim]")
45
+
46
+
47
+ def print_success(message: str) -> None:
48
+ """Print a success message to stderr."""
49
+ console.print(f"[bold green]✓[/bold green] {message}")
50
+
51
+
52
+ def print_warning(message: str) -> None:
53
+ """Print a warning to stderr."""
54
+ console.print(f"[bold yellow]⚠[/bold yellow] {message}")
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Spinner / status context manager
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ @contextmanager
63
+ def spinner(message: str) -> Iterator[None]:
64
+ """Show a spinner on stderr while the body runs."""
65
+ with console.status(message):
66
+ yield
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Table helpers
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def print_table(
75
+ columns: list[str | tuple[str, dict]],
76
+ rows: list[list[Any]],
77
+ *,
78
+ title: str | None = None,
79
+ ) -> None:
80
+ """Render a Rich table to stdout.
81
+
82
+ *columns* may be plain strings or ``(header, style_kwargs)`` tuples.
83
+ """
84
+ table = Table(title=title, show_header=True, header_style="bold cyan")
85
+ for col in columns:
86
+ if isinstance(col, str):
87
+ table.add_column(col)
88
+ else:
89
+ header, kwargs = col
90
+ table.add_column(header, **kwargs)
91
+ for row in rows:
92
+ table.add_row(*[str(v) if v is not None else "—" for v in row])
93
+ data_console.print(table)
94
+
95
+
96
+ def print_json(data: Any) -> None:
97
+ """Print *data* as pretty JSON to stdout."""
98
+ data_console.print_json(json.dumps(data, default=str))
99
+
100
+
101
+ def print_json_or_table(
102
+ data: list[dict] | dict,
103
+ columns: list[str | tuple[str, dict]],
104
+ rows: list[list[Any]],
105
+ *,
106
+ as_json: bool,
107
+ title: str | None = None,
108
+ ) -> None:
109
+ """Output as JSON (``--json``) or a Rich table."""
110
+ if as_json:
111
+ print_json(data)
112
+ else:
113
+ print_table(columns, rows, title=title)
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Streaming agent response renderer
118
+ # ---------------------------------------------------------------------------
119
+
120
+ # SSE event type constants (mirrors teardrop_sdk)
121
+ _EV_TEXT = "text_msg_content"
122
+ _EV_TOOL_START = "tool_call_start"
123
+ _EV_TOOL_END = "tool_call_end"
124
+ _EV_USAGE = "usage_summary"
125
+ _EV_BILLING = "billing_settlement"
126
+ _EV_ERROR = "error"
127
+ _EV_DONE = "done"
128
+
129
+
130
+ def stream_agent_response(events: AsyncIterator) -> None: # type: ignore[type-arg]
131
+ """Render a streaming agent run to the terminal.
132
+
133
+ Blocks the calling thread by running the async iterator inside
134
+ ``asyncio.run()``. Renders token-by-token text, tool call indicators,
135
+ and a usage summary at the end.
136
+ """
137
+ asyncio.run(_render_stream(events))
138
+
139
+
140
+ async def _render_stream(events: AsyncIterator) -> None: # type: ignore[type-arg]
141
+ """Async implementation of the streaming renderer."""
142
+ accumulated_text = ""
143
+ tool_depth = 0 # track nested tool calls
144
+
145
+ # We use a Live display so we can update the rendered Markdown in-place.
146
+ # ``auto_refresh=False`` lets us control exactly when re-renders happen.
147
+ live = Live(
148
+ Text(""),
149
+ console=console,
150
+ refresh_per_second=15,
151
+ auto_refresh=True,
152
+ transient=False,
153
+ )
154
+
155
+ with live:
156
+ async for event in events:
157
+ ev_type: str = getattr(event, "type", "") or ""
158
+ data = getattr(event, "data", None)
159
+
160
+ if ev_type == _EV_TEXT:
161
+ chunk = ""
162
+ if isinstance(data, dict):
163
+ chunk = data.get("content", "")
164
+ elif isinstance(data, str):
165
+ chunk = data
166
+ accumulated_text += chunk
167
+ live.update(Markdown(accumulated_text))
168
+
169
+ elif ev_type == _EV_TOOL_START:
170
+ tool_depth += 1
171
+ tool_name = ""
172
+ if isinstance(data, dict):
173
+ tool_name = data.get("tool_name", data.get("name", ""))
174
+ indicator = f"\n\n*[Tool: {tool_name}…]*\n"
175
+ live.update(Markdown(accumulated_text + indicator))
176
+
177
+ elif ev_type == _EV_TOOL_END:
178
+ tool_depth = max(0, tool_depth - 1)
179
+ if tool_depth == 0:
180
+ # Clear inline indicator; response text continues
181
+ live.update(Markdown(accumulated_text))
182
+
183
+ elif ev_type == _EV_USAGE:
184
+ # Print usage summary below the response
185
+ if isinstance(data, dict):
186
+ _print_usage_summary(data)
187
+
188
+ elif ev_type == _EV_BILLING:
189
+ if isinstance(data, dict):
190
+ _print_billing_settlement(data)
191
+
192
+ elif ev_type == _EV_ERROR:
193
+ msg = ""
194
+ if isinstance(data, dict):
195
+ msg = data.get("message", str(data))
196
+ elif isinstance(data, str):
197
+ msg = data
198
+ live.stop()
199
+ print_error(f"Agent error: {msg}")
200
+ return
201
+
202
+ elif ev_type == _EV_DONE:
203
+ break
204
+
205
+ # Ensure a newline after streaming output
206
+ console.print()
207
+
208
+
209
+ def _print_usage_summary(data: dict) -> None:
210
+ parts: list[str] = []
211
+ if "input_tokens" in data:
212
+ parts.append(f"Input: {data['input_tokens']} tok")
213
+ if "output_tokens" in data:
214
+ parts.append(f"Output: {data['output_tokens']} tok")
215
+ if "total_cost_usd" in data:
216
+ parts.append(f"Cost: ${data['total_cost_usd']:.4f}")
217
+ if parts:
218
+ console.print(f"[dim]{' · '.join(parts)}[/dim]")
219
+
220
+
221
+ def _print_billing_settlement(data: dict) -> None:
222
+ amount = data.get("amount_charged")
223
+ currency = data.get("currency", "credits")
224
+ if amount is not None:
225
+ console.print(f"[dim]Charged: {amount} {currency}[/dim]")
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Confirmation prompt
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def confirm(message: str, *, default: bool = False) -> bool:
234
+ """Prompt the user for a yes/no confirmation on stderr."""
235
+ suffix = " [Y/n] " if default else " [y/N] "
236
+ try:
237
+ answer = console.input(f"[bold]{message}[/bold]{suffix}").strip().lower()
238
+ except (EOFError, KeyboardInterrupt):
239
+ console.print()
240
+ return False
241
+ if not answer:
242
+ return default
243
+ return answer in ("y", "yes")