milo-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.
milo/gateway.py ADDED
@@ -0,0 +1,393 @@
1
+ """Milo MCP gateway — one MCP server that proxies to all registered CLIs.
2
+
3
+ Register this once with your AI host:
4
+
5
+ claude mcp add milo -- uv run python -m milo.gateway --mcp
6
+
7
+ Then every CLI registered via ``--mcp-install`` is discoverable.
8
+ Tools are namespaced: ``taskman.add``, ``ghub.repo.list``, etc.
9
+
10
+ Can also be run directly for debugging:
11
+
12
+ uv run python -m milo.gateway --mcp
13
+ uv run python -m milo.gateway --list
14
+ uv run python -m milo.gateway --doctor
15
+ uv run python -m milo.gateway --status
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import sys
22
+ import threading
23
+ import time
24
+ from typing import Any
25
+
26
+ from milo._child import ChildProcess
27
+ from milo.registry import list_clis
28
+
29
+ _MCP_VERSION = "2025-11-25"
30
+
31
+
32
+ def main() -> None:
33
+ """Entry point for ``python -m milo.gateway``."""
34
+ if "--list" in sys.argv:
35
+ _print_registry()
36
+ return
37
+ if "--doctor" in sys.argv:
38
+ from milo.registry import doctor
39
+
40
+ sys.stdout.write(doctor())
41
+ return
42
+ if "--status" in sys.argv:
43
+ _print_status()
44
+ return
45
+ if "--mcp" in sys.argv:
46
+ _run_gateway()
47
+ return
48
+ # Default: show help
49
+ sys.stderr.write("milo gateway — one MCP server for all your CLIs\n\n")
50
+ sys.stderr.write("Usage:\n")
51
+ sys.stderr.write(" python -m milo.gateway --mcp Run as MCP server\n")
52
+ sys.stderr.write(" python -m milo.gateway --list List registered CLIs\n")
53
+ sys.stderr.write(" python -m milo.gateway --doctor Health check all CLIs\n")
54
+ sys.stderr.write(" python -m milo.gateway --status Show stats and children\n")
55
+ sys.stderr.write("\nRegister CLIs with: myapp --mcp-install\n")
56
+
57
+
58
+ def _print_registry() -> None:
59
+ """Print all registered CLIs."""
60
+ clis = list_clis()
61
+ if not clis:
62
+ sys.stderr.write("No CLIs registered. Use --mcp-install on a milo CLI.\n")
63
+ return
64
+ for name, info in clis.items():
65
+ desc = info.get("description", "")
66
+ ver = info.get("version", "")
67
+ cmd = " ".join(info.get("command", []))
68
+ label = f"{name} {ver}".strip()
69
+ sys.stdout.write(f"{label}\n")
70
+ if desc:
71
+ sys.stdout.write(f" {desc}\n")
72
+ sys.stdout.write(f" {cmd}\n\n")
73
+
74
+
75
+ def _print_status() -> None:
76
+ """Print gateway status (placeholder for F7 observability)."""
77
+ clis = list_clis()
78
+ sys.stdout.write(f"Registered CLIs: {len(clis)}\n")
79
+ for name in clis:
80
+ sys.stdout.write(f" {name}\n")
81
+
82
+
83
+ def _run_gateway() -> None:
84
+ """Run the MCP gateway server with persistent child processes."""
85
+ clis = list_clis()
86
+
87
+ # Create persistent children for each CLI
88
+ children: dict[str, ChildProcess] = {}
89
+ for cli_name, info in clis.items():
90
+ command = info.get("command", [])
91
+ if command:
92
+ children[cli_name] = ChildProcess(cli_name, command)
93
+
94
+ # Discover tools from all registered CLIs
95
+ all_tools, tool_routing = _discover_tools(clis, children)
96
+ tool_names = [t["name"] for t in all_tools]
97
+
98
+ # Discover resources and prompts
99
+ all_resources, resource_routing = _discover_resources(clis, children)
100
+ all_prompts, prompt_routing = _discover_prompts(clis, children)
101
+
102
+ _stderr("milo gateway ready")
103
+ _stderr(f" Protocol: {_MCP_VERSION}")
104
+ _stderr(f" CLIs: {len(clis)} ({', '.join(clis.keys()) if clis else 'none'})")
105
+ _stderr(f" Tools: {len(all_tools)}")
106
+ _stderr(f" Resources: {len(all_resources)}")
107
+ _stderr(f" Prompts: {len(all_prompts)}")
108
+ if tool_names:
109
+ _stderr(f" Available: {', '.join(tool_names)}")
110
+ _stderr("")
111
+ _stderr("Press Ctrl+C to stop.")
112
+ _stderr("")
113
+
114
+ # Start idle reaper thread
115
+ reaper = threading.Thread(target=_idle_reaper, args=(children,), daemon=True)
116
+ reaper.start()
117
+
118
+ try:
119
+ for line in sys.stdin:
120
+ line = line.strip()
121
+ if not line:
122
+ continue
123
+ try:
124
+ request = json.loads(line)
125
+ except json.JSONDecodeError:
126
+ _write_error(None, -32700, "Parse error")
127
+ continue
128
+
129
+ req_id = request.get("id")
130
+ method = request.get("method", "")
131
+
132
+ try:
133
+ result = _handle_method(
134
+ clis,
135
+ all_tools,
136
+ tool_routing,
137
+ all_resources,
138
+ resource_routing,
139
+ all_prompts,
140
+ prompt_routing,
141
+ children,
142
+ method,
143
+ request.get("params", {}),
144
+ )
145
+ if result is not None:
146
+ _write_result(req_id, result)
147
+ except Exception as e:
148
+ _write_error(req_id, -32603, str(e))
149
+ finally:
150
+ # Clean up children on exit
151
+ for child in children.values():
152
+ child.kill()
153
+
154
+
155
+ def _idle_reaper(children: dict[str, ChildProcess]) -> None:
156
+ """Periodically check and kill idle children."""
157
+ while True:
158
+ time.sleep(60)
159
+ for child in list(children.values()):
160
+ if child.is_idle():
161
+ _stderr(f" Reaping idle child: {child.name}")
162
+ child.kill()
163
+
164
+
165
+ def _discover_tools(
166
+ clis: dict[str, dict[str, Any]],
167
+ children: dict[str, ChildProcess],
168
+ ) -> tuple[list[dict[str, Any]], dict[str, tuple[str, str]]]:
169
+ """Discover tools from all registered CLIs via persistent children."""
170
+ all_tools: list[dict[str, Any]] = []
171
+ routing: dict[str, tuple[str, str]] = {}
172
+
173
+ for cli_name in clis:
174
+ child = children.get(cli_name)
175
+ if not child:
176
+ continue
177
+
178
+ try:
179
+ tools = child.fetch_tools()
180
+ except Exception as e:
181
+ _stderr(f" Warning: failed to discover {cli_name}: {e}")
182
+ continue
183
+
184
+ for tool in tools:
185
+ original_name = tool["name"]
186
+ namespaced = f"{cli_name}.{original_name}"
187
+ tool["name"] = namespaced
188
+ if "title" not in tool:
189
+ tool["title"] = f"{cli_name}: {tool.get('description', original_name)}"
190
+ all_tools.append(tool)
191
+ routing[namespaced] = (cli_name, original_name)
192
+
193
+ return all_tools, routing
194
+
195
+
196
+ def _discover_resources(
197
+ clis: dict[str, dict[str, Any]],
198
+ children: dict[str, ChildProcess],
199
+ ) -> tuple[list[dict[str, Any]], dict[str, tuple[str, str]]]:
200
+ """Discover resources from all registered CLIs."""
201
+ all_resources: list[dict[str, Any]] = []
202
+ routing: dict[str, tuple[str, str]] = {}
203
+
204
+ for cli_name in clis:
205
+ child = children.get(cli_name)
206
+ if not child:
207
+ continue
208
+
209
+ try:
210
+ result = child.send_call("resources/list", {})
211
+ resources = result.get("resources", [])
212
+ except Exception:
213
+ continue
214
+
215
+ for resource in resources:
216
+ original_uri = resource["uri"]
217
+ namespaced_uri = f"{cli_name}/{original_uri}"
218
+ resource["uri"] = namespaced_uri
219
+ all_resources.append(resource)
220
+ routing[namespaced_uri] = (cli_name, original_uri)
221
+
222
+ return all_resources, routing
223
+
224
+
225
+ def _discover_prompts(
226
+ clis: dict[str, dict[str, Any]],
227
+ children: dict[str, ChildProcess],
228
+ ) -> tuple[list[dict[str, Any]], dict[str, tuple[str, str]]]:
229
+ """Discover prompts from all registered CLIs."""
230
+ all_prompts: list[dict[str, Any]] = []
231
+ routing: dict[str, tuple[str, str]] = {}
232
+
233
+ for cli_name in clis:
234
+ child = children.get(cli_name)
235
+ if not child:
236
+ continue
237
+
238
+ try:
239
+ result = child.send_call("prompts/list", {})
240
+ prompts = result.get("prompts", [])
241
+ except Exception:
242
+ continue
243
+
244
+ for prompt in prompts:
245
+ original_name = prompt["name"]
246
+ namespaced = f"{cli_name}.{original_name}"
247
+ prompt["name"] = namespaced
248
+ all_prompts.append(prompt)
249
+ routing[namespaced] = (cli_name, original_name)
250
+
251
+ return all_prompts, routing
252
+
253
+
254
+ def _handle_method(
255
+ clis: dict[str, dict[str, Any]],
256
+ all_tools: list[dict[str, Any]],
257
+ tool_routing: dict[str, tuple[str, str]],
258
+ all_resources: list[dict[str, Any]],
259
+ resource_routing: dict[str, tuple[str, str]],
260
+ all_prompts: list[dict[str, Any]],
261
+ prompt_routing: dict[str, tuple[str, str]],
262
+ children: dict[str, ChildProcess],
263
+ method: str,
264
+ params: dict[str, Any],
265
+ ) -> dict[str, Any] | None:
266
+ """Dispatch an MCP method."""
267
+ match method:
268
+ case "initialize":
269
+ cli_names = list(clis.keys())
270
+ return {
271
+ "protocolVersion": _MCP_VERSION,
272
+ "capabilities": {"tools": {}, "resources": {}, "prompts": {}},
273
+ "serverInfo": {
274
+ "name": "milo-gateway",
275
+ "version": "0.1.0",
276
+ "title": "Milo Gateway",
277
+ },
278
+ "instructions": (
279
+ f"Gateway to {len(clis)} milo CLIs: {', '.join(cli_names)}. "
280
+ "Tools are namespaced as cli_name.command_name."
281
+ ),
282
+ }
283
+ case "notifications/initialized":
284
+ return None
285
+ case "tools/list":
286
+ return {"tools": all_tools}
287
+ case "tools/call":
288
+ return _proxy_call(children, tool_routing, params)
289
+ case "resources/list":
290
+ return {"resources": all_resources}
291
+ case "resources/read":
292
+ return _proxy_resource(children, resource_routing, params)
293
+ case "prompts/list":
294
+ return {"prompts": all_prompts}
295
+ case "prompts/get":
296
+ return _proxy_prompt(children, prompt_routing, params)
297
+ case _:
298
+ raise ValueError(f"Unknown method: {method!r}")
299
+
300
+
301
+ def _proxy_call(
302
+ children: dict[str, ChildProcess],
303
+ tool_routing: dict[str, tuple[str, str]],
304
+ params: dict[str, Any],
305
+ ) -> dict[str, Any]:
306
+ """Proxy a tools/call to the appropriate child process."""
307
+ tool_name = params.get("name", "")
308
+ arguments = params.get("arguments", {})
309
+
310
+ if tool_name not in tool_routing:
311
+ return {
312
+ "content": [{"type": "text", "text": f"Unknown tool: {tool_name!r}"}],
313
+ "isError": True,
314
+ }
315
+
316
+ cli_name, original_name = tool_routing[tool_name]
317
+ child = children.get(cli_name)
318
+ if not child:
319
+ return {
320
+ "content": [{"type": "text", "text": f"CLI {cli_name!r} not available"}],
321
+ "isError": True,
322
+ }
323
+
324
+ result = child.send_call("tools/call", {"name": original_name, "arguments": arguments})
325
+ if "error" in result:
326
+ return {
327
+ "content": [{"type": "text", "text": result["error"].get("message", "Unknown error")}],
328
+ "isError": True,
329
+ }
330
+ return result
331
+
332
+
333
+ def _proxy_resource(
334
+ children: dict[str, ChildProcess],
335
+ resource_routing: dict[str, tuple[str, str]],
336
+ params: dict[str, Any],
337
+ ) -> dict[str, Any]:
338
+ """Proxy a resources/read to the appropriate child process."""
339
+ uri = params.get("uri", "")
340
+ if uri not in resource_routing:
341
+ return {"contents": []}
342
+
343
+ cli_name, original_uri = resource_routing[uri]
344
+ child = children.get(cli_name)
345
+ if not child:
346
+ return {"contents": []}
347
+
348
+ return child.send_call("resources/read", {"uri": original_uri})
349
+
350
+
351
+ def _proxy_prompt(
352
+ children: dict[str, ChildProcess],
353
+ prompt_routing: dict[str, tuple[str, str]],
354
+ params: dict[str, Any],
355
+ ) -> dict[str, Any]:
356
+ """Proxy a prompts/get to the appropriate child process."""
357
+ name = params.get("name", "")
358
+ if name not in prompt_routing:
359
+ return {"messages": []}
360
+
361
+ cli_name, original_name = prompt_routing[name]
362
+ child = children.get(cli_name)
363
+ if not child:
364
+ return {"messages": []}
365
+
366
+ return child.send_call(
367
+ "prompts/get", {"name": original_name, "arguments": params.get("arguments", {})}
368
+ )
369
+
370
+
371
+ def _write_result(req_id: Any, result: dict[str, Any]) -> None:
372
+ response = {"jsonrpc": "2.0", "id": req_id, "result": result}
373
+ sys.stdout.write(json.dumps(response) + "\n")
374
+ sys.stdout.flush()
375
+
376
+
377
+ def _write_error(req_id: Any, code: int, message: str) -> None:
378
+ response = {
379
+ "jsonrpc": "2.0",
380
+ "id": req_id,
381
+ "error": {"code": code, "message": message},
382
+ }
383
+ sys.stdout.write(json.dumps(response) + "\n")
384
+ sys.stdout.flush()
385
+
386
+
387
+ def _stderr(message: str) -> None:
388
+ sys.stderr.write(message + "\n")
389
+ sys.stderr.flush()
390
+
391
+
392
+ if __name__ == "__main__":
393
+ main()
milo/groups.py ADDED
@@ -0,0 +1,194 @@
1
+ """Nestable command groups for hierarchical CLI structures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from milo.schema import function_to_schema
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class GroupDef:
14
+ """Frozen snapshot of a command group."""
15
+
16
+ name: str
17
+ description: str
18
+ commands: dict[str, Any] = field(default_factory=dict) # str -> CommandDef
19
+ groups: dict[str, GroupDef] = field(default_factory=dict)
20
+ aliases: tuple[str, ...] = ()
21
+ hidden: bool = False
22
+
23
+
24
+ class Group:
25
+ """A nestable command namespace.
26
+
27
+ Usage::
28
+
29
+ site = cli.group("site", description="Site operations")
30
+
31
+ @site.command("build", description="Build the site")
32
+ def build(output: str = "_site") -> str: ...
33
+
34
+ config = site.group("config", description="Config management")
35
+
36
+ @config.command("show", description="Show merged config")
37
+ def show() -> dict: ...
38
+
39
+ CLI::
40
+
41
+ myapp site build --output _site
42
+ myapp site config show
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ name: str,
48
+ *,
49
+ description: str = "",
50
+ aliases: tuple[str, ...] | list[str] = (),
51
+ hidden: bool = False,
52
+ ) -> None:
53
+ self.name = name
54
+ self.description = description
55
+ self.aliases = tuple(aliases)
56
+ self.hidden = hidden
57
+ self._commands: dict[str, Any] = {} # str -> CommandDef
58
+ self._alias_map: dict[str, str] = {}
59
+ self._groups: dict[str, Group] = {}
60
+ self._group_alias_map: dict[str, str] = {}
61
+
62
+ def command(
63
+ self,
64
+ name: str,
65
+ *,
66
+ description: str = "",
67
+ aliases: tuple[str, ...] | list[str] = (),
68
+ tags: tuple[str, ...] | list[str] = (),
69
+ hidden: bool = False,
70
+ ) -> Callable:
71
+ """Register a function as a command within this group."""
72
+ from milo.commands import CommandDef
73
+
74
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
75
+ schema = function_to_schema(func)
76
+ desc = description or func.__doc__ or ""
77
+ if "\n" in desc:
78
+ desc = desc.strip().split("\n")[0].strip()
79
+
80
+ cmd = CommandDef(
81
+ name=name,
82
+ description=desc,
83
+ handler=func,
84
+ schema=schema,
85
+ aliases=tuple(aliases),
86
+ tags=tuple(tags),
87
+ hidden=hidden,
88
+ )
89
+ self._commands[name] = cmd
90
+ for alias in aliases:
91
+ self._alias_map[alias] = name
92
+
93
+ return func
94
+
95
+ return decorator
96
+
97
+ def lazy_command(
98
+ self,
99
+ name: str,
100
+ import_path: str,
101
+ *,
102
+ description: str = "",
103
+ schema: dict[str, Any] | None = None,
104
+ aliases: tuple[str, ...] | list[str] = (),
105
+ tags: tuple[str, ...] | list[str] = (),
106
+ hidden: bool = False,
107
+ ) -> Any:
108
+ """Register a lazy-loaded command within this group.
109
+
110
+ The handler module is not imported until the command is invoked.
111
+ """
112
+ from milo.commands import LazyCommandDef
113
+
114
+ cmd = LazyCommandDef(
115
+ name=name,
116
+ import_path=import_path,
117
+ description=description,
118
+ schema=schema,
119
+ aliases=aliases,
120
+ tags=tags,
121
+ hidden=hidden,
122
+ )
123
+ self._commands[name] = cmd
124
+ for alias in aliases:
125
+ self._alias_map[alias] = name
126
+ return cmd
127
+
128
+ def group(
129
+ self,
130
+ name: str,
131
+ *,
132
+ description: str = "",
133
+ aliases: tuple[str, ...] | list[str] = (),
134
+ hidden: bool = False,
135
+ ) -> Group:
136
+ """Create and register a sub-group. Returns it for chaining."""
137
+ sub = Group(name, description=description, aliases=aliases, hidden=hidden)
138
+ self._groups[name] = sub
139
+ for alias in aliases:
140
+ self._group_alias_map[alias] = name
141
+ return sub
142
+
143
+ def add_group(self, group: Group) -> None:
144
+ """Add an existing Group as a sub-group."""
145
+ self._groups[group.name] = group
146
+ for alias in group.aliases:
147
+ self._group_alias_map[alias] = group.name
148
+
149
+ @property
150
+ def commands(self) -> dict[str, Any]:
151
+ """All registered commands in this group."""
152
+ return dict(self._commands)
153
+
154
+ @property
155
+ def groups(self) -> dict[str, Group]:
156
+ """All registered sub-groups."""
157
+ return dict(self._groups)
158
+
159
+ def get_command(self, name: str) -> Any | None:
160
+ """Look up a command by name or alias within this group."""
161
+ if name in self._commands:
162
+ return self._commands[name]
163
+ resolved = self._alias_map.get(name)
164
+ if resolved:
165
+ return self._commands.get(resolved)
166
+ return None
167
+
168
+ def get_group(self, name: str) -> Group | None:
169
+ """Look up a sub-group by name or alias."""
170
+ if name in self._groups:
171
+ return self._groups[name]
172
+ resolved = self._group_alias_map.get(name)
173
+ if resolved:
174
+ return self._groups.get(resolved)
175
+ return None
176
+
177
+ def to_def(self) -> GroupDef:
178
+ """Freeze into immutable GroupDef tree."""
179
+ return GroupDef(
180
+ name=self.name,
181
+ description=self.description,
182
+ commands=dict(self._commands),
183
+ groups={n: g.to_def() for n, g in self._groups.items()},
184
+ aliases=self.aliases,
185
+ hidden=self.hidden,
186
+ )
187
+
188
+ def walk_commands(self, prefix: str = "") -> list[tuple[str, Any]]:
189
+ """Yield (dotted_path, CommandDef) for all commands in this tree."""
190
+ path_prefix = f"{prefix}{self.name}." if prefix else f"{self.name}."
191
+ result = [(f"{path_prefix}{cmd.name}", cmd) for cmd in self._commands.values()]
192
+ for group in self._groups.values():
193
+ result.extend(group.walk_commands(path_prefix))
194
+ return result
milo/help.py ADDED
@@ -0,0 +1,84 @@
1
+ """HelpRenderer — argparse formatter_class for styled help output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class HelpState:
12
+ """State for help rendering."""
13
+
14
+ prog: str = ""
15
+ description: str = ""
16
+ epilog: str = ""
17
+ usage: str = ""
18
+ groups: tuple[dict[str, Any], ...] = ()
19
+
20
+
21
+ class HelpRenderer(argparse.HelpFormatter):
22
+ """Argparse formatter that renders through kida templates.
23
+
24
+ Usage::
25
+
26
+ parser = argparse.ArgumentParser(
27
+ formatter_class=HelpRenderer,
28
+ )
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ prog: str,
34
+ indent_increment: int = 2,
35
+ max_help_position: int = 24,
36
+ width: int | None = None,
37
+ ) -> None:
38
+ super().__init__(prog, indent_increment, max_help_position, width)
39
+ self._prog = prog
40
+
41
+ def format_help(self) -> str:
42
+ """Format help using kida template if available, else fall back to default."""
43
+ try:
44
+ return self._render_with_template()
45
+ except Exception:
46
+ return super().format_help()
47
+
48
+ def _render_with_template(self) -> str:
49
+ """Render help through the kida help template."""
50
+ from milo.templates import get_env
51
+
52
+ env = get_env()
53
+ template = env.get_template("help.kida")
54
+
55
+ groups = []
56
+ for action_group in self._action_groups: # type: ignore[attr-defined]
57
+ actions = [
58
+ {
59
+ "option_strings": action.option_strings,
60
+ "dest": action.dest,
61
+ "help": action.help or "",
62
+ "default": action.default,
63
+ "required": getattr(action, "required", False),
64
+ "choices": action.choices,
65
+ "nargs": action.nargs,
66
+ "metavar": action.metavar,
67
+ }
68
+ for action in action_group._group_actions
69
+ ]
70
+ if actions:
71
+ groups.append(
72
+ {
73
+ "title": action_group.title or "",
74
+ "actions": actions,
75
+ }
76
+ )
77
+
78
+ state = HelpState(
79
+ prog=self._prog,
80
+ description=self._root_section.heading or "",
81
+ groups=tuple(groups),
82
+ )
83
+
84
+ return template.render(state=state)
milo/input/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Input handling — key reading and escape sequence parsing."""
2
+
3
+ from milo._types import Key, SpecialKey
4
+ from milo.input._reader import KeyReader
5
+
6
+ __all__ = ["Key", "KeyReader", "SpecialKey"]