cli-web-hackernews 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,188 @@
1
+ """``doctor`` — self-diagnosis for cli-web-* CLIs.
2
+
3
+ CANONICAL SOURCE: cli-web-core/cli_web_core/doctor.py
4
+ Vendored into every generated CLI at cli_web/<app>/utils/doctor.py by
5
+ `cli-web-devkit resync`. Do not edit vendored copies by hand.
6
+
7
+ Checks the local environment a support thread would ask about first:
8
+ installation, Python version, config directory, auth material (when the
9
+ CLI has an auth module), and optional dependencies. Read-only — never
10
+ mutates state, never touches the network.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import json
17
+ import os
18
+ import shutil
19
+ import stat
20
+ import sys
21
+ from dataclasses import asdict, dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ @dataclass
27
+ class DoctorCheck:
28
+ name: str
29
+ status: str # "ok" | "warn" | "fail"
30
+ detail: str = ""
31
+
32
+
33
+ def _check_entry_point(app_name: str) -> DoctorCheck:
34
+ binary = f"cli-web-{app_name}"
35
+ path = shutil.which(binary)
36
+ if path:
37
+ return DoctorCheck("entry point", "ok", path)
38
+ return DoctorCheck(
39
+ "entry point",
40
+ "warn",
41
+ f"{binary} not on PATH — run `pip install -e .` in agent-harness/ "
42
+ f"(python -m fallback still works)",
43
+ )
44
+
45
+
46
+ def _check_python() -> DoctorCheck:
47
+ # Intentional runtime guard: direct-source runs bypass pip's
48
+ # python_requires, so the interpreter check must live here.
49
+ if sys.version_info >= (3, 10): # noqa: UP036
50
+ return DoctorCheck("python", "ok", sys.version.split()[0])
51
+ return DoctorCheck("python", "fail", f"{sys.version.split()[0]} < 3.10 (unsupported)")
52
+
53
+
54
+ def _config_dir(app_name: str) -> Path:
55
+ return Path.home() / ".config" / f"cli-web-{app_name}"
56
+
57
+
58
+ def _check_config_dir(app_name: str) -> DoctorCheck:
59
+ cfg = _config_dir(app_name)
60
+ if not cfg.exists():
61
+ return DoctorCheck("config dir", "ok", f"{cfg} (not created yet — created on first use)")
62
+ if os.access(cfg, os.W_OK):
63
+ return DoctorCheck("config dir", "ok", str(cfg))
64
+ return DoctorCheck("config dir", "fail", f"{cfg} is not writable")
65
+
66
+
67
+ def _has_auth_module(pkg: str) -> bool:
68
+ try:
69
+ return importlib.util.find_spec(f"cli_web.{pkg}.core.auth") is not None
70
+ except (ImportError, ModuleNotFoundError, ValueError):
71
+ return False
72
+
73
+
74
+ def _check_auth(app_name: str, pkg: str) -> list[DoctorCheck]:
75
+ if not _has_auth_module(pkg):
76
+ return [DoctorCheck("auth", "ok", "no auth module — public site, nothing to configure")]
77
+
78
+ checks: list[DoctorCheck] = []
79
+ if importlib.util.find_spec("playwright") is None:
80
+ checks.append(
81
+ DoctorCheck(
82
+ "playwright",
83
+ "warn",
84
+ "not installed — `auth login` (browser flow) unavailable; "
85
+ "pip install playwright && playwright install chromium",
86
+ )
87
+ )
88
+ else:
89
+ checks.append(DoctorCheck("playwright", "ok", "installed"))
90
+
91
+ env_var = f"CLI_WEB_{app_name.upper().replace('-', '_')}_AUTH_JSON"
92
+ if os.environ.get(env_var):
93
+ checks.append(DoctorCheck("auth source", "ok", f"using env var {env_var}"))
94
+ return checks
95
+
96
+ auth_file = _config_dir(app_name) / "auth.json"
97
+ if not auth_file.is_file():
98
+ checks.append(
99
+ DoctorCheck(
100
+ "auth file",
101
+ "warn",
102
+ f"{auth_file} missing — run: cli-web-{app_name} auth login (or set {env_var})",
103
+ )
104
+ )
105
+ return checks
106
+
107
+ checks.append(DoctorCheck("auth file", "ok", str(auth_file)))
108
+ if os.name == "posix": # st_mode permission bits are meaningless on Windows
109
+ mode = stat.S_IMODE(auth_file.stat().st_mode)
110
+ if mode & 0o077:
111
+ checks.append(
112
+ DoctorCheck(
113
+ "auth file permissions",
114
+ "warn",
115
+ f"{oct(mode)} — should be 600; run: chmod 600 {auth_file}",
116
+ )
117
+ )
118
+ else:
119
+ checks.append(DoctorCheck("auth file permissions", "ok", oct(mode)))
120
+ try:
121
+ json.loads(auth_file.read_text(encoding="utf-8"))
122
+ checks.append(DoctorCheck("auth file format", "ok", "valid JSON"))
123
+ except (OSError, json.JSONDecodeError) as exc:
124
+ checks.append(DoctorCheck("auth file format", "fail", f"unreadable: {exc}"))
125
+
126
+ return checks
127
+
128
+
129
+ def _check_optional_deps() -> list[DoctorCheck]:
130
+ checks = []
131
+ if importlib.util.find_spec("prompt_toolkit") is None:
132
+ checks.append(
133
+ DoctorCheck("prompt_toolkit", "ok", "not installed — REPL uses plain input()")
134
+ )
135
+ else:
136
+ checks.append(DoctorCheck("prompt_toolkit", "ok", "installed (REPL autocomplete on)"))
137
+ return checks
138
+
139
+
140
+ def run_doctor(app_name: str, pkg: str) -> list[DoctorCheck]:
141
+ checks = [
142
+ _check_python(),
143
+ _check_entry_point(app_name),
144
+ _check_config_dir(app_name),
145
+ *_check_auth(app_name, pkg),
146
+ *_check_optional_deps(),
147
+ ]
148
+ return checks
149
+
150
+
151
+ def register_doctor_command(cli: Any, app_name: str, pkg: str | None = None) -> None:
152
+ """Attach a ``doctor`` command to a cli-web-* Click group."""
153
+ import click
154
+
155
+ resolved_pkg = pkg or app_name.replace("-", "_")
156
+
157
+ @cli.command("doctor")
158
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
159
+ @click.pass_context
160
+ def doctor(ctx: Any, json_mode: bool) -> None:
161
+ """Diagnose this CLI's local setup (install, auth, dependencies)."""
162
+ if not json_mode: # honor the group-level --json flag (ctx.obj["json"])
163
+ obj = ctx.find_root().obj
164
+ json_mode = bool(obj.get("json")) if isinstance(obj, dict) else False
165
+ checks = run_doctor(app_name, resolved_pkg)
166
+ failed = [c for c in checks if c.status == "fail"]
167
+ if json_mode:
168
+ click.echo(
169
+ json.dumps(
170
+ {
171
+ "success": not failed,
172
+ "data": {
173
+ "checks": [asdict(c) for c in checks],
174
+ "ok": not failed,
175
+ },
176
+ },
177
+ indent=2,
178
+ )
179
+ )
180
+ else:
181
+ marks = {"ok": "✓", "warn": "⚠", "fail": "✗"}
182
+ for c in checks:
183
+ detail = f" {c.detail}" if c.detail else ""
184
+ click.echo(f" {marks[c.status]} {c.name}:{detail}")
185
+ click.echo()
186
+ click.echo("all good" if not failed else f"{len(failed)} problem(s) found")
187
+ if failed:
188
+ raise SystemExit(1)
@@ -0,0 +1,73 @@
1
+ """Shared helpers for cli-web-hackernews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sys
10
+
11
+ import click
12
+ from cli_web.hackernews.core.exceptions import AppError
13
+
14
+
15
+ @contextlib.contextmanager
16
+ def handle_errors(json_mode: bool = False):
17
+ """Context manager for consistent error handling in commands."""
18
+ try:
19
+ yield
20
+ except AppError as exc:
21
+ if json_mode:
22
+ click.echo(json.dumps(exc.to_dict()), err=False)
23
+ else:
24
+ click.echo(f"Error: {exc.message}", err=True)
25
+ sys.exit(1)
26
+ except Exception as exc:
27
+ if json_mode:
28
+ click.echo(
29
+ json.dumps(
30
+ {
31
+ "error": True,
32
+ "code": "UNEXPECTED_ERROR",
33
+ "message": str(exc),
34
+ }
35
+ ),
36
+ err=False,
37
+ )
38
+ else:
39
+ click.echo(f"Error: {exc}", err=True)
40
+ sys.exit(1)
41
+
42
+
43
+ def resolve_json_mode(use_json: bool) -> bool:
44
+ """Resolve --json flag, checking parent context too."""
45
+ if use_json:
46
+ return True
47
+ ctx = click.get_current_context(silent=True)
48
+ if ctx and ctx.obj:
49
+ return ctx.obj.get("json", False)
50
+ return False
51
+
52
+
53
+ def print_json(data) -> None:
54
+ """Print data as formatted JSON."""
55
+ click.echo(json.dumps(data, indent=2, default=str))
56
+
57
+
58
+ def _resolve_cli(name: str) -> str:
59
+ """Find the CLI binary path for subprocess tests."""
60
+ # Check if forced to use installed version
61
+ if os.environ.get("CLI_WEB_FORCE_INSTALLED"):
62
+ path = shutil.which(name)
63
+ if path:
64
+ return path
65
+ raise FileNotFoundError(f"{name} not found in PATH")
66
+
67
+ # Try which first
68
+ path = shutil.which(name)
69
+ if path:
70
+ return path
71
+
72
+ # Fallback to python -m
73
+ return f"{sys.executable} -m cli_web.hackernews"
@@ -0,0 +1,290 @@
1
+ """MCP server adapter — expose a cli-web-* Click CLI as MCP tools.
2
+
3
+ CANONICAL SOURCE: cli-web-core/cli_web_core/mcp_server.py
4
+ Vendored into every generated CLI at cli_web/<app>/utils/mcp_server.py by
5
+ `cli-web-devkit resync`. Do not edit vendored copies by hand.
6
+
7
+ Every cli-web-* command already speaks ``--json``, so the Click command
8
+ tree maps 1:1 onto MCP tools: tool names are ``group_subcommand``, input
9
+ schemas are derived from Click parameters, and each ``tools/call`` spawns
10
+ the CLI as a fresh subprocess with ``--json`` forced, returning the JSON
11
+ envelope as the tool result. Spawning per call (rather than running
12
+ in-process) gives every tool the same clean-process isolation as a normal
13
+ CLI invocation — no auth/session/global state leaks between calls, and a
14
+ wedged command cannot hang the server. Transport: MCP stdio
15
+ (newline-delimited JSON-RPC 2.0).
16
+
17
+ Usage (wired automatically into generated CLIs)::
18
+
19
+ cli-web-<app> mcp-serve
20
+
21
+ Then point any MCP client at that command.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import sys
28
+ from collections.abc import Callable
29
+ from typing import Any
30
+
31
+ PROTOCOL_VERSION = "2024-11-05"
32
+
33
+ _CLICK_TYPE_MAP = {
34
+ "integer": "integer",
35
+ "int": "integer",
36
+ "float": "number",
37
+ "boolean": "boolean",
38
+ "bool": "boolean",
39
+ }
40
+
41
+
42
+ def _is_multi_valued(param: Any) -> bool:
43
+ return bool(getattr(param, "multiple", False)) or getattr(param, "nargs", 1) != 1
44
+
45
+
46
+ def _param_schema(param: Any) -> dict[str, Any]:
47
+ type_name = getattr(param.type, "name", "text") or "text"
48
+ json_type = _CLICK_TYPE_MAP.get(type_name.lower(), "string")
49
+ schema: dict[str, Any] = {"type": json_type}
50
+ choices = getattr(param.type, "choices", None)
51
+ if choices:
52
+ schema["enum"] = list(choices)
53
+ if _is_multi_valued(param):
54
+ schema = {"type": "array", "items": schema}
55
+ help_text = getattr(param, "help", None)
56
+ if help_text:
57
+ schema["description"] = help_text
58
+ return schema
59
+
60
+
61
+ def _iter_leaf_commands(
62
+ group: Any, prefix: tuple[str, ...] = ()
63
+ ) -> list[tuple[tuple[str, ...], Any]]:
64
+ """Flatten a Click group into (path, command) leaves."""
65
+ import click
66
+
67
+ leaves: list[tuple[tuple[str, ...], Any]] = []
68
+ for name in sorted(group.commands):
69
+ cmd = group.commands[name]
70
+ if getattr(cmd, "hidden", False) or name == "mcp-serve":
71
+ continue
72
+ path = (*prefix, name)
73
+ if isinstance(cmd, click.Group):
74
+ leaves.extend(_iter_leaf_commands(cmd, path))
75
+ else:
76
+ leaves.append((path, cmd))
77
+ return leaves
78
+
79
+
80
+ def _is_json_flag(param: Any) -> bool:
81
+ return "--json" in getattr(param, "opts", ()) or param.name in ("json_mode", "as_json")
82
+
83
+
84
+ def _tool_for(path: tuple[str, ...], cmd: Any) -> dict[str, Any]:
85
+ properties: dict[str, Any] = {}
86
+ required: list[str] = []
87
+ for param in cmd.params:
88
+ if _is_json_flag(param) or param.name == "help":
89
+ continue
90
+ properties[param.name] = _param_schema(param)
91
+ if getattr(param, "required", False):
92
+ required.append(param.name)
93
+ schema: dict[str, Any] = {"type": "object", "properties": properties}
94
+ if required:
95
+ schema["required"] = required
96
+ return {
97
+ "name": "_".join(path).replace("-", "_"),
98
+ "description": (cmd.help or cmd.short_help or " ".join(path)).strip(),
99
+ "inputSchema": schema,
100
+ }
101
+
102
+
103
+ def _build_argv(
104
+ path: tuple[str, ...], cmd: Any, arguments: dict[str, Any], json_flag: bool
105
+ ) -> list[str]:
106
+ """Translate MCP tool arguments back into a Click argv."""
107
+ import click
108
+
109
+ argv = list(path)
110
+ for param in cmd.params:
111
+ if _is_json_flag(param) or param.name not in arguments:
112
+ continue
113
+ value = arguments[param.name]
114
+ if value is None:
115
+ continue
116
+ values = list(value) if isinstance(value, (list, tuple)) else [value]
117
+ if isinstance(param, click.Argument):
118
+ argv.extend(str(v) for v in values)
119
+ elif getattr(param, "is_flag", False):
120
+ if value:
121
+ argv.append(param.opts[0])
122
+ elif _is_multi_valued(param):
123
+ for v in values:
124
+ argv.extend([param.opts[0], str(v)])
125
+ else:
126
+ argv.extend([param.opts[0], str(value)])
127
+ if json_flag:
128
+ argv.append("--json")
129
+ return argv
130
+
131
+
132
+ def _cmd_supports_json(cmd: Any) -> bool:
133
+ return any(_is_json_flag(p) for p in cmd.params)
134
+
135
+
136
+ class McpServer:
137
+ def __init__(
138
+ self,
139
+ cli: Any,
140
+ app_name: str,
141
+ version: str = "0.1.0",
142
+ pkg: str | None = None,
143
+ *,
144
+ timeout: float = 300.0,
145
+ executor: Callable[[list[str]], tuple[str, bool]] | None = None,
146
+ ):
147
+ self.cli = cli
148
+ self.app_name = app_name
149
+ self.version = version
150
+ #: Namespace sub-package, for the ``python -m`` subprocess fallback.
151
+ self.pkg = pkg or app_name.replace("-", "_")
152
+ #: Per-call subprocess timeout (seconds).
153
+ self.timeout = timeout
154
+ #: Optional ``(argv) -> (text, is_error)`` override. Defaults to a
155
+ #: fresh subprocess per call; injected in tests to run an in-memory
156
+ #: Click group without an installed binary.
157
+ self._executor = executor
158
+ self._leaves: dict[str, tuple[tuple[str, ...], Any]] = {}
159
+ for path, cmd in _iter_leaf_commands(cli):
160
+ tool_name = "_".join(path).replace("-", "_")
161
+ self._leaves[tool_name] = (path, cmd)
162
+
163
+ # ── JSON-RPC handlers ────────────────────────────────────────────
164
+
165
+ def handle(self, message: dict[str, Any]) -> dict[str, Any] | None:
166
+ method = message.get("method", "")
167
+ msg_id = message.get("id")
168
+ if method == "initialize":
169
+ return self._result(
170
+ msg_id,
171
+ {
172
+ "protocolVersion": PROTOCOL_VERSION,
173
+ "capabilities": {"tools": {}},
174
+ "serverInfo": {
175
+ "name": f"cli-web-{self.app_name}",
176
+ "version": self.version,
177
+ },
178
+ },
179
+ )
180
+ if method.startswith("notifications/"):
181
+ return None
182
+ if method == "tools/list":
183
+ tools = [_tool_for(path, cmd) for path, cmd in self._leaves.values()]
184
+ return self._result(msg_id, {"tools": tools})
185
+ if method == "tools/call":
186
+ return self._call_tool(msg_id, message.get("params") or {})
187
+ if method == "ping":
188
+ return self._result(msg_id, {})
189
+ return self._error(msg_id, -32601, f"Method not found: {method}")
190
+
191
+ def _call_tool(self, msg_id: Any, params: dict[str, Any]) -> dict[str, Any]:
192
+ name = params.get("name", "")
193
+ if name not in self._leaves:
194
+ return self._error(msg_id, -32602, f"Unknown tool: {name}")
195
+ path, cmd = self._leaves[name]
196
+ arguments = params.get("arguments") or {}
197
+ argv = _build_argv(path, cmd, arguments, json_flag=_cmd_supports_json(cmd))
198
+
199
+ executor = self._executor or self._subprocess_execute
200
+ text, is_error = executor(argv)
201
+ return self._result(
202
+ msg_id,
203
+ {
204
+ "content": [{"type": "text", "text": text}],
205
+ "isError": is_error,
206
+ },
207
+ )
208
+
209
+ # ── command execution ────────────────────────────────────────────
210
+
211
+ def _base_command(self) -> list[str]:
212
+ """Resolve how to invoke this CLI as a subprocess.
213
+
214
+ Prefer the installed console script; fall back to ``python -m`` so the
215
+ server still works from a source checkout without ``pip install``.
216
+ """
217
+ import shutil
218
+
219
+ binary = shutil.which(f"cli-web-{self.app_name}")
220
+ if binary:
221
+ return [binary]
222
+ return [sys.executable, "-m", f"cli_web.{self.pkg}"]
223
+
224
+ def _subprocess_execute(self, argv: list[str]) -> tuple[str, bool]:
225
+ """Run one tool call as a fresh subprocess — full process isolation.
226
+
227
+ Each call is a clean process, identical to how a user (and the test
228
+ suite) invokes the CLI, so no auth/session/global state leaks between
229
+ calls and a stuck command cannot wedge the server.
230
+ """
231
+ import subprocess
232
+
233
+ command = [*self._base_command(), *argv]
234
+ try:
235
+ proc = subprocess.run(
236
+ command,
237
+ capture_output=True,
238
+ text=True,
239
+ timeout=self.timeout,
240
+ check=False,
241
+ )
242
+ except subprocess.TimeoutExpired:
243
+ return (f"command timed out after {self.timeout}s", True)
244
+ except (FileNotFoundError, OSError) as exc:
245
+ return (f"failed to run cli-web-{self.app_name}: {exc}", True)
246
+ # The --json success and error envelopes both go to stdout; fall back
247
+ # to stderr for hard failures (e.g. a Click usage error, exit 2).
248
+ text = proc.stdout.strip() or proc.stderr.strip()
249
+ return (text, proc.returncode != 0)
250
+
251
+ @staticmethod
252
+ def _result(msg_id: Any, result: dict[str, Any]) -> dict[str, Any]:
253
+ return {"jsonrpc": "2.0", "id": msg_id, "result": result}
254
+
255
+ @staticmethod
256
+ def _error(msg_id: Any, code: int, message: str) -> dict[str, Any]:
257
+ return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
258
+
259
+ # ── stdio loop ───────────────────────────────────────────────────
260
+
261
+ def serve_stdio(self) -> None:
262
+ for line in sys.stdin:
263
+ line = line.strip()
264
+ if not line:
265
+ continue
266
+ try:
267
+ message = json.loads(line)
268
+ except json.JSONDecodeError:
269
+ print(
270
+ json.dumps(self._error(None, -32700, "Parse error")),
271
+ flush=True,
272
+ )
273
+ continue
274
+ response = self.handle(message)
275
+ if response is not None:
276
+ print(json.dumps(response), flush=True)
277
+
278
+
279
+ def register_mcp_command(
280
+ cli: Any, app_name: str, version: str = "0.1.0", pkg: str | None = None
281
+ ) -> None:
282
+ """Attach an ``mcp-serve`` command to a cli-web-* Click group."""
283
+ import click
284
+
285
+ @cli.command("mcp-serve", hidden=False)
286
+ def mcp_serve() -> None:
287
+ """Serve this CLI as an MCP server over stdio (newline JSON-RPC)."""
288
+ McpServer(cli, app_name=app_name, version=version, pkg=pkg).serve_stdio()
289
+
290
+ _ = click # imported for parity with vendored runtime deps
@@ -0,0 +1,136 @@
1
+ """Output formatting for cli-web-hackernews (JSON and human-readable tables)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+
10
+ def _safe(text: str, width: int = 0) -> str:
11
+ """Truncate text to width and replace un-encodable characters."""
12
+ if width:
13
+ text = text[:width]
14
+ encoding = getattr(sys.stdout, "encoding", "utf-8") or "utf-8"
15
+ return text.encode(encoding, errors="replace").decode(encoding)
16
+
17
+
18
+ def print_json(data: Any) -> None:
19
+ """Print data as formatted JSON to stdout."""
20
+ print(json.dumps(data, indent=2, default=str))
21
+
22
+
23
+ def print_error_json(error: Exception) -> None:
24
+ """Print an error as JSON."""
25
+ from cli_web.hackernews.core.exceptions import AppError
26
+
27
+ if isinstance(error, AppError):
28
+ print_json(error.to_dict())
29
+ else:
30
+ print_json({"error": True, "code": "UNKNOWN_ERROR", "message": str(error)})
31
+
32
+
33
+ def print_stories_table(stories: list) -> None:
34
+ """Print stories as a human-readable table."""
35
+ if not stories:
36
+ print("No stories found.")
37
+ return
38
+
39
+ col_rank = 4
40
+ col_title = 55
41
+ col_score = 6
42
+ col_by = 14
43
+ col_comments = 8
44
+ col_age = 8
45
+
46
+ header = (
47
+ f"{'#':<{col_rank}} "
48
+ f"{'Title':<{col_title}} "
49
+ f"{'Pts':>{col_score}} "
50
+ f"{'By':<{col_by}} "
51
+ f"{'Cmts':>{col_comments}} "
52
+ f"{'Age':>{col_age}}"
53
+ )
54
+ print(header)
55
+ print("-" * len(header))
56
+
57
+ for i, story in enumerate(stories, start=1):
58
+ title = _safe(story.title, col_title)
59
+ by = _safe(story.by, col_by)
60
+ print(
61
+ f"{i:<{col_rank}} "
62
+ f"{title:<{col_title}} "
63
+ f"{story.score:>{col_score}} "
64
+ f"{by:<{col_by}} "
65
+ f"{story.descendants:>{col_comments}} "
66
+ f"{story.age:>{col_age}}"
67
+ )
68
+
69
+
70
+ def print_comments_list(comments: list) -> None:
71
+ """Print comments in a readable format."""
72
+ if not comments:
73
+ print("No comments found.")
74
+ return
75
+
76
+ for i, comment in enumerate(comments, start=1):
77
+ by = comment.by or "[deleted]"
78
+ age = comment.age
79
+ text = comment.text_plain[:200]
80
+ if len(comment.text_plain) > 200:
81
+ text += "..."
82
+ replies = len(comment.kids)
83
+ reply_note = f" ({replies} replies)" if replies else ""
84
+
85
+ print(f" {i}. {by} — {age}{reply_note}")
86
+ print(f" {text}")
87
+ print()
88
+
89
+
90
+ def print_user_profile(user) -> None:
91
+ """Print user profile in a readable format."""
92
+ print(f" Username: {user.id}")
93
+ print(f" Karma: {user.karma:,}")
94
+ print(f" Member since:{user.member_since}")
95
+ if user.about_plain:
96
+ about = user.about_plain[:300]
97
+ if len(user.about_plain) > 300:
98
+ about += "..."
99
+ print(f" About: {about}")
100
+ print(f" Submissions: {len(user.submitted):,}")
101
+
102
+
103
+ def print_search_results_table(results: list) -> None:
104
+ """Print search results as a table."""
105
+ if not results:
106
+ print("No results found.")
107
+ return
108
+
109
+ col_id = 10
110
+ col_title = 55
111
+ col_pts = 6
112
+ col_by = 14
113
+ col_cmts = 6
114
+
115
+ header = (
116
+ f"{'ID':<{col_id}} "
117
+ f"{'Title':<{col_title}} "
118
+ f"{'Pts':>{col_pts}} "
119
+ f"{'By':<{col_by}} "
120
+ f"{'Cmts':>{col_cmts}}"
121
+ )
122
+ print(header)
123
+ print("-" * len(header))
124
+
125
+ for result in results:
126
+ title = _safe(result.title or "", col_title)
127
+ by = _safe(result.author, col_by)
128
+ pts = result.points if result.points is not None else 0
129
+ cmts = result.num_comments if result.num_comments is not None else 0
130
+ print(
131
+ f"{result.objectID:<{col_id}} "
132
+ f"{title:<{col_title}} "
133
+ f"{pts:>{col_pts}} "
134
+ f"{by:<{col_by}} "
135
+ f"{cmts:>{col_cmts}}"
136
+ )