cli-web-codewiki 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/codewiki/__init__.py +3 -0
- cli_web/codewiki/__main__.py +6 -0
- cli_web/codewiki/codewiki_cli.py +142 -0
- cli_web/codewiki/commands/__init__.py +0 -0
- cli_web/codewiki/commands/chat.py +46 -0
- cli_web/codewiki/commands/repos.py +90 -0
- cli_web/codewiki/commands/wiki.py +267 -0
- cli_web/codewiki/core/__init__.py +0 -0
- cli_web/codewiki/core/client.py +224 -0
- cli_web/codewiki/core/exceptions.py +74 -0
- cli_web/codewiki/core/models.py +91 -0
- cli_web/codewiki/core/rpc/__init__.py +0 -0
- cli_web/codewiki/core/rpc/decoder.py +86 -0
- cli_web/codewiki/core/rpc/encoder.py +32 -0
- cli_web/codewiki/core/rpc/types.py +27 -0
- cli_web/codewiki/tests/__init__.py +0 -0
- cli_web/codewiki/tests/test_core.py +725 -0
- cli_web/codewiki/tests/test_e2e.py +411 -0
- cli_web/codewiki/utils/__init__.py +0 -0
- cli_web/codewiki/utils/config.py +14 -0
- cli_web/codewiki/utils/doctor.py +188 -0
- cli_web/codewiki/utils/helpers.py +67 -0
- cli_web/codewiki/utils/mcp_server.py +290 -0
- cli_web/codewiki/utils/output.py +11 -0
- cli_web/codewiki/utils/repl_skin.py +486 -0
- cli_web_codewiki-0.1.0.dist-info/METADATA +14 -0
- cli_web_codewiki-0.1.0.dist-info/RECORD +30 -0
- cli_web_codewiki-0.1.0.dist-info/WHEEL +5 -0
- cli_web_codewiki-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_codewiki-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,11 @@
|
|
|
1
|
+
"""Output formatting for cli-web-codewiki (JSON and human-readable tables)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def print_json(data: Any) -> None:
|
|
10
|
+
"""Print data as formatted JSON to stdout."""
|
|
11
|
+
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|