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.
- cli_web/hackernews/README.md +91 -0
- cli_web/hackernews/__init__.py +0 -0
- cli_web/hackernews/__main__.py +6 -0
- cli_web/hackernews/commands/__init__.py +0 -0
- cli_web/hackernews/commands/actions.py +105 -0
- cli_web/hackernews/commands/auth.py +80 -0
- cli_web/hackernews/commands/search.py +69 -0
- cli_web/hackernews/commands/stories.py +160 -0
- cli_web/hackernews/commands/user.py +112 -0
- cli_web/hackernews/core/__init__.py +0 -0
- cli_web/hackernews/core/auth.py +290 -0
- cli_web/hackernews/core/client.py +517 -0
- cli_web/hackernews/core/exceptions.py +63 -0
- cli_web/hackernews/core/models.py +144 -0
- cli_web/hackernews/hackernews_cli.py +171 -0
- cli_web/hackernews/tests/TEST.md +143 -0
- cli_web/hackernews/tests/__init__.py +0 -0
- cli_web/hackernews/tests/test_core.py +365 -0
- cli_web/hackernews/tests/test_e2e.py +267 -0
- cli_web/hackernews/utils/__init__.py +0 -0
- cli_web/hackernews/utils/doctor.py +188 -0
- cli_web/hackernews/utils/helpers.py +73 -0
- cli_web/hackernews/utils/mcp_server.py +290 -0
- cli_web/hackernews/utils/output.py +136 -0
- cli_web/hackernews/utils/repl_skin.py +486 -0
- cli_web_hackernews-0.1.0.dist-info/METADATA +12 -0
- cli_web_hackernews-0.1.0.dist-info/RECORD +30 -0
- cli_web_hackernews-0.1.0.dist-info/WHEEL +5 -0
- cli_web_hackernews-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_hackernews-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|