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/__init__.py +183 -0
- milo/_child.py +141 -0
- milo/_errors.py +189 -0
- milo/_protocols.py +37 -0
- milo/_types.py +234 -0
- milo/app.py +353 -0
- milo/cli.py +134 -0
- milo/commands.py +951 -0
- milo/config.py +250 -0
- milo/context.py +81 -0
- milo/dev.py +238 -0
- milo/flow.py +146 -0
- milo/form.py +277 -0
- milo/gateway.py +393 -0
- milo/groups.py +194 -0
- milo/help.py +84 -0
- milo/input/__init__.py +6 -0
- milo/input/_platform.py +81 -0
- milo/input/_reader.py +93 -0
- milo/input/_sequences.py +63 -0
- milo/llms.py +172 -0
- milo/mcp.py +299 -0
- milo/middleware.py +67 -0
- milo/observability.py +111 -0
- milo/output.py +106 -0
- milo/pipeline.py +276 -0
- milo/plugins.py +168 -0
- milo/py.typed +0 -0
- milo/registry.py +213 -0
- milo/schema.py +214 -0
- milo/state.py +229 -0
- milo/streaming.py +41 -0
- milo/templates/__init__.py +38 -0
- milo/templates/error.kida +5 -0
- milo/templates/field_confirm.kida +1 -0
- milo/templates/field_select.kida +3 -0
- milo/templates/field_text.kida +1 -0
- milo/templates/form.kida +8 -0
- milo/templates/help.kida +7 -0
- milo/templates/progress.kida +1 -0
- milo/testing/__init__.py +27 -0
- milo/testing/_mcp.py +87 -0
- milo/testing/_record.py +125 -0
- milo/testing/_replay.py +68 -0
- milo/testing/_snapshot.py +96 -0
- milo_cli-0.1.0.dist-info/METADATA +441 -0
- milo_cli-0.1.0.dist-info/RECORD +50 -0
- milo_cli-0.1.0.dist-info/WHEEL +5 -0
- milo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- milo_cli-0.1.0.dist-info/top_level.txt +1 -0
milo/commands.py
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
"""CLI application with command decorator and dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
import importlib
|
|
8
|
+
import inspect
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from milo.output import write_output
|
|
17
|
+
from milo.schema import function_to_schema
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from milo.context import Context
|
|
21
|
+
from milo.groups import Group
|
|
22
|
+
from milo.middleware import MiddlewareStack
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class GlobalOption:
|
|
27
|
+
"""A CLI-wide option available to all commands via Context."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
short: str = ""
|
|
31
|
+
option_type: type = str
|
|
32
|
+
default: Any = None
|
|
33
|
+
description: str = ""
|
|
34
|
+
is_flag: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ResourceDef:
|
|
39
|
+
"""A registered MCP resource."""
|
|
40
|
+
|
|
41
|
+
uri: str
|
|
42
|
+
name: str
|
|
43
|
+
description: str
|
|
44
|
+
handler: Callable[..., Any]
|
|
45
|
+
mime_type: str = "text/plain"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class PromptDef:
|
|
50
|
+
"""A registered MCP prompt."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
description: str
|
|
54
|
+
handler: Callable[..., Any]
|
|
55
|
+
arguments: tuple[dict[str, Any], ...] = ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class CommandDef:
|
|
60
|
+
"""A registered CLI command."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
description: str
|
|
64
|
+
handler: Callable[..., Any]
|
|
65
|
+
schema: dict[str, Any]
|
|
66
|
+
aliases: tuple[str, ...] = ()
|
|
67
|
+
tags: tuple[str, ...] = ()
|
|
68
|
+
hidden: bool = False
|
|
69
|
+
examples: tuple[dict[str, Any], ...] = ()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LazyCommandDef:
|
|
73
|
+
"""A command whose handler is imported on first use.
|
|
74
|
+
|
|
75
|
+
Stores a dotted import path (``module:attribute``) and defers the
|
|
76
|
+
actual import until the command is invoked. This keeps CLI startup
|
|
77
|
+
fast even with dozens of commands.
|
|
78
|
+
|
|
79
|
+
If *schema* is provided upfront, MCP ``tools/list`` and llms.txt
|
|
80
|
+
can be generated without importing the handler module at all.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
__slots__ = (
|
|
84
|
+
"_lock",
|
|
85
|
+
"_resolved",
|
|
86
|
+
"_schema",
|
|
87
|
+
"aliases",
|
|
88
|
+
"description",
|
|
89
|
+
"examples",
|
|
90
|
+
"hidden",
|
|
91
|
+
"import_path",
|
|
92
|
+
"name",
|
|
93
|
+
"tags",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
name: str,
|
|
99
|
+
import_path: str,
|
|
100
|
+
description: str = "",
|
|
101
|
+
*,
|
|
102
|
+
schema: dict[str, Any] | None = None,
|
|
103
|
+
aliases: tuple[str, ...] | list[str] = (),
|
|
104
|
+
tags: tuple[str, ...] | list[str] = (),
|
|
105
|
+
hidden: bool = False,
|
|
106
|
+
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
107
|
+
) -> None:
|
|
108
|
+
self.name = name
|
|
109
|
+
self.description = description
|
|
110
|
+
self.import_path = import_path
|
|
111
|
+
self.aliases = tuple(aliases)
|
|
112
|
+
self.tags = tuple(tags)
|
|
113
|
+
self.hidden = hidden
|
|
114
|
+
self.examples = tuple(examples)
|
|
115
|
+
self._schema = schema
|
|
116
|
+
self._resolved: CommandDef | None = None
|
|
117
|
+
self._lock = threading.Lock()
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def schema(self) -> dict[str, Any]:
|
|
121
|
+
"""Return pre-computed schema or resolve to get it."""
|
|
122
|
+
if self._schema is not None:
|
|
123
|
+
return self._schema
|
|
124
|
+
return self.resolve().schema
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def handler(self) -> Callable[..., Any]:
|
|
128
|
+
"""Resolve and return the handler function."""
|
|
129
|
+
return self.resolve().handler
|
|
130
|
+
|
|
131
|
+
def resolve(self) -> CommandDef:
|
|
132
|
+
"""Import the handler and cache as a full CommandDef. Thread-safe."""
|
|
133
|
+
if self._resolved is not None:
|
|
134
|
+
return self._resolved
|
|
135
|
+
|
|
136
|
+
with self._lock:
|
|
137
|
+
# Double-check after acquiring lock
|
|
138
|
+
if self._resolved is not None:
|
|
139
|
+
return self._resolved
|
|
140
|
+
|
|
141
|
+
module_path, _, attr_name = self.import_path.rpartition(":")
|
|
142
|
+
if not module_path or not attr_name:
|
|
143
|
+
msg = f"Invalid import_path {self.import_path!r}: expected 'module.path:attribute'"
|
|
144
|
+
raise ValueError(msg)
|
|
145
|
+
|
|
146
|
+
module = importlib.import_module(module_path)
|
|
147
|
+
handler = getattr(module, attr_name)
|
|
148
|
+
|
|
149
|
+
schema = self._schema if self._schema is not None else function_to_schema(handler)
|
|
150
|
+
|
|
151
|
+
self._resolved = CommandDef(
|
|
152
|
+
name=self.name,
|
|
153
|
+
description=self.description,
|
|
154
|
+
handler=handler,
|
|
155
|
+
schema=schema,
|
|
156
|
+
aliases=self.aliases,
|
|
157
|
+
tags=self.tags,
|
|
158
|
+
hidden=self.hidden,
|
|
159
|
+
examples=self.examples,
|
|
160
|
+
)
|
|
161
|
+
return self._resolved
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class CLI:
|
|
165
|
+
"""Command-line application with typed commands and nested groups.
|
|
166
|
+
|
|
167
|
+
Each @command becomes a CLI subcommand, an MCP tool, and a help entry.
|
|
168
|
+
Groups create nested command namespaces.
|
|
169
|
+
|
|
170
|
+
Usage::
|
|
171
|
+
|
|
172
|
+
cli = CLI(name="myapp", description="My tool", version="1.0.0")
|
|
173
|
+
|
|
174
|
+
@cli.command("greet", description="Say hello")
|
|
175
|
+
def greet(name: str, loud: bool = False) -> str:
|
|
176
|
+
msg = f"Hello, {name}!"
|
|
177
|
+
return msg.upper() if loud else msg
|
|
178
|
+
|
|
179
|
+
site = cli.group("site", description="Site operations")
|
|
180
|
+
|
|
181
|
+
@site.command("build", description="Build the site")
|
|
182
|
+
def build(output: str = "_site") -> str:
|
|
183
|
+
return f"Building to {output}"
|
|
184
|
+
|
|
185
|
+
cli.run()
|
|
186
|
+
|
|
187
|
+
CLI::
|
|
188
|
+
|
|
189
|
+
myapp greet --name Alice
|
|
190
|
+
myapp site build --output _site
|
|
191
|
+
myapp --llms-txt
|
|
192
|
+
myapp --mcp
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
*,
|
|
198
|
+
name: str = "",
|
|
199
|
+
description: str = "",
|
|
200
|
+
version: str = "",
|
|
201
|
+
) -> None:
|
|
202
|
+
self.name = name or "app"
|
|
203
|
+
self.description = description
|
|
204
|
+
self.version = version
|
|
205
|
+
self._commands: dict[str, CommandDef | LazyCommandDef] = {}
|
|
206
|
+
self._alias_map: dict[str, str] = {}
|
|
207
|
+
self._groups: dict[str, Group] = {}
|
|
208
|
+
self._group_alias_map: dict[str, str] = {}
|
|
209
|
+
self._global_options: list[GlobalOption] = []
|
|
210
|
+
self._resources: dict[str, ResourceDef] = {}
|
|
211
|
+
self._prompts: dict[str, PromptDef] = {}
|
|
212
|
+
self._middleware: MiddlewareStack | None = None
|
|
213
|
+
|
|
214
|
+
def global_option(
|
|
215
|
+
self,
|
|
216
|
+
name: str,
|
|
217
|
+
*,
|
|
218
|
+
short: str = "",
|
|
219
|
+
option_type: type = str,
|
|
220
|
+
default: Any = None,
|
|
221
|
+
description: str = "",
|
|
222
|
+
is_flag: bool = False,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Register a global option available to all commands via Context.
|
|
225
|
+
|
|
226
|
+
Usage::
|
|
227
|
+
|
|
228
|
+
cli.global_option("environment", short="-e", default="local",
|
|
229
|
+
description="Config environment")
|
|
230
|
+
"""
|
|
231
|
+
self._global_options.append(
|
|
232
|
+
GlobalOption(
|
|
233
|
+
name=name,
|
|
234
|
+
short=short,
|
|
235
|
+
option_type=option_type,
|
|
236
|
+
default=default,
|
|
237
|
+
description=description,
|
|
238
|
+
is_flag=is_flag,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def command(
|
|
243
|
+
self,
|
|
244
|
+
name: str,
|
|
245
|
+
*,
|
|
246
|
+
description: str = "",
|
|
247
|
+
aliases: tuple[str, ...] | list[str] = (),
|
|
248
|
+
tags: tuple[str, ...] | list[str] = (),
|
|
249
|
+
hidden: bool = False,
|
|
250
|
+
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
251
|
+
) -> Callable:
|
|
252
|
+
"""Register a function as a CLI command.
|
|
253
|
+
|
|
254
|
+
The function's type annotations drive:
|
|
255
|
+
- argparse argument generation
|
|
256
|
+
- MCP tool schema
|
|
257
|
+
- help text
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
261
|
+
schema = function_to_schema(func)
|
|
262
|
+
desc = description or func.__doc__ or ""
|
|
263
|
+
if "\n" in desc:
|
|
264
|
+
desc = desc.strip().split("\n")[0].strip()
|
|
265
|
+
|
|
266
|
+
cmd = CommandDef(
|
|
267
|
+
name=name,
|
|
268
|
+
description=desc,
|
|
269
|
+
handler=func,
|
|
270
|
+
schema=schema,
|
|
271
|
+
aliases=tuple(aliases),
|
|
272
|
+
tags=tuple(tags),
|
|
273
|
+
hidden=hidden,
|
|
274
|
+
examples=tuple(examples),
|
|
275
|
+
)
|
|
276
|
+
self._commands[name] = cmd
|
|
277
|
+
for alias in aliases:
|
|
278
|
+
self._alias_map[alias] = name
|
|
279
|
+
|
|
280
|
+
return func
|
|
281
|
+
|
|
282
|
+
return decorator
|
|
283
|
+
|
|
284
|
+
def lazy_command(
|
|
285
|
+
self,
|
|
286
|
+
name: str,
|
|
287
|
+
import_path: str,
|
|
288
|
+
*,
|
|
289
|
+
description: str = "",
|
|
290
|
+
schema: dict[str, Any] | None = None,
|
|
291
|
+
aliases: tuple[str, ...] | list[str] = (),
|
|
292
|
+
tags: tuple[str, ...] | list[str] = (),
|
|
293
|
+
hidden: bool = False,
|
|
294
|
+
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
295
|
+
) -> LazyCommandDef:
|
|
296
|
+
"""Register a lazy-loaded command.
|
|
297
|
+
|
|
298
|
+
The handler module is not imported until the command is invoked.
|
|
299
|
+
This keeps CLI startup fast for large command sets.
|
|
300
|
+
"""
|
|
301
|
+
cmd = LazyCommandDef(
|
|
302
|
+
name=name,
|
|
303
|
+
import_path=import_path,
|
|
304
|
+
description=description,
|
|
305
|
+
schema=schema,
|
|
306
|
+
aliases=aliases,
|
|
307
|
+
tags=tags,
|
|
308
|
+
hidden=hidden,
|
|
309
|
+
examples=examples,
|
|
310
|
+
)
|
|
311
|
+
self._commands[name] = cmd
|
|
312
|
+
for alias in aliases:
|
|
313
|
+
self._alias_map[alias] = name
|
|
314
|
+
return cmd
|
|
315
|
+
|
|
316
|
+
def resource(
|
|
317
|
+
self,
|
|
318
|
+
uri: str,
|
|
319
|
+
*,
|
|
320
|
+
name: str = "",
|
|
321
|
+
description: str = "",
|
|
322
|
+
mime_type: str = "text/plain",
|
|
323
|
+
) -> Callable:
|
|
324
|
+
"""Register a function as an MCP resource.
|
|
325
|
+
|
|
326
|
+
Usage::
|
|
327
|
+
|
|
328
|
+
@cli.resource("config://app", description="App config")
|
|
329
|
+
def get_config() -> dict: ...
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
333
|
+
resource_name = name or func.__name__
|
|
334
|
+
res = ResourceDef(
|
|
335
|
+
uri=uri,
|
|
336
|
+
name=resource_name,
|
|
337
|
+
description=description,
|
|
338
|
+
handler=func,
|
|
339
|
+
mime_type=mime_type,
|
|
340
|
+
)
|
|
341
|
+
self._resources[uri] = res
|
|
342
|
+
return func
|
|
343
|
+
|
|
344
|
+
return decorator
|
|
345
|
+
|
|
346
|
+
def prompt(
|
|
347
|
+
self,
|
|
348
|
+
name: str,
|
|
349
|
+
*,
|
|
350
|
+
description: str = "",
|
|
351
|
+
arguments: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
352
|
+
) -> Callable:
|
|
353
|
+
"""Register a function as an MCP prompt.
|
|
354
|
+
|
|
355
|
+
Usage::
|
|
356
|
+
|
|
357
|
+
@cli.prompt("deploy-checklist", description="Pre-deploy steps")
|
|
358
|
+
def checklist(environment: str) -> list[dict]: ...
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
362
|
+
# Auto-derive arguments from function signature if not provided
|
|
363
|
+
args = tuple(arguments)
|
|
364
|
+
if not args:
|
|
365
|
+
sig = inspect.signature(func)
|
|
366
|
+
derived = []
|
|
367
|
+
for pname, param in sig.parameters.items():
|
|
368
|
+
arg: dict[str, Any] = {"name": pname}
|
|
369
|
+
if param.default is not inspect.Parameter.empty:
|
|
370
|
+
arg["required"] = False
|
|
371
|
+
else:
|
|
372
|
+
arg["required"] = True
|
|
373
|
+
derived.append(arg)
|
|
374
|
+
args = tuple(derived)
|
|
375
|
+
|
|
376
|
+
p = PromptDef(
|
|
377
|
+
name=name,
|
|
378
|
+
description=description,
|
|
379
|
+
handler=func,
|
|
380
|
+
arguments=args,
|
|
381
|
+
)
|
|
382
|
+
self._prompts[name] = p
|
|
383
|
+
return func
|
|
384
|
+
|
|
385
|
+
return decorator
|
|
386
|
+
|
|
387
|
+
def middleware(self, fn: Callable) -> Callable:
|
|
388
|
+
"""Register a middleware function.
|
|
389
|
+
|
|
390
|
+
Usage::
|
|
391
|
+
|
|
392
|
+
@cli.middleware
|
|
393
|
+
def log_calls(ctx, call, next_fn):
|
|
394
|
+
result = next_fn(call)
|
|
395
|
+
return result
|
|
396
|
+
"""
|
|
397
|
+
if self._middleware is None:
|
|
398
|
+
from milo.middleware import MiddlewareStack
|
|
399
|
+
|
|
400
|
+
self._middleware = MiddlewareStack()
|
|
401
|
+
self._middleware.use(fn)
|
|
402
|
+
return fn
|
|
403
|
+
|
|
404
|
+
def group(
|
|
405
|
+
self,
|
|
406
|
+
name: str,
|
|
407
|
+
*,
|
|
408
|
+
description: str = "",
|
|
409
|
+
aliases: tuple[str, ...] | list[str] = (),
|
|
410
|
+
hidden: bool = False,
|
|
411
|
+
) -> Group:
|
|
412
|
+
"""Create and register a command group.
|
|
413
|
+
|
|
414
|
+
Returns the Group for registering commands within it::
|
|
415
|
+
|
|
416
|
+
site = cli.group("site", description="Site operations")
|
|
417
|
+
|
|
418
|
+
@site.command("build", description="Build the site")
|
|
419
|
+
def build(output: str = "_site") -> str: ...
|
|
420
|
+
"""
|
|
421
|
+
from milo.groups import Group as GroupClass
|
|
422
|
+
|
|
423
|
+
grp = GroupClass(name, description=description, aliases=aliases, hidden=hidden)
|
|
424
|
+
self._groups[name] = grp
|
|
425
|
+
for alias in aliases:
|
|
426
|
+
self._group_alias_map[alias] = name
|
|
427
|
+
return grp
|
|
428
|
+
|
|
429
|
+
def add_group(self, group: Group) -> None:
|
|
430
|
+
"""Register an externally-created Group."""
|
|
431
|
+
self._groups[group.name] = group
|
|
432
|
+
for alias in group.aliases:
|
|
433
|
+
self._group_alias_map[alias] = group.name
|
|
434
|
+
|
|
435
|
+
def mount(self, prefix: str, other: CLI) -> None:
|
|
436
|
+
"""Mount another CLI as a command group. In-process, no subprocess.
|
|
437
|
+
|
|
438
|
+
Usage::
|
|
439
|
+
|
|
440
|
+
main = CLI(name="main")
|
|
441
|
+
sub = CLI(name="sub")
|
|
442
|
+
main.mount("sub", sub)
|
|
443
|
+
# sub's commands are now at main.sub.*
|
|
444
|
+
"""
|
|
445
|
+
from milo.groups import Group as GroupClass
|
|
446
|
+
|
|
447
|
+
grp = GroupClass(prefix, description=other.description)
|
|
448
|
+
|
|
449
|
+
# Mount commands
|
|
450
|
+
for cmd_name, cmd in other._commands.items():
|
|
451
|
+
grp._commands[cmd_name] = cmd
|
|
452
|
+
if hasattr(cmd, "aliases"):
|
|
453
|
+
for alias in cmd.aliases:
|
|
454
|
+
grp._alias_map[alias] = cmd_name
|
|
455
|
+
|
|
456
|
+
# Mount sub-groups
|
|
457
|
+
for gname, g in other._groups.items():
|
|
458
|
+
grp._groups[gname] = g
|
|
459
|
+
for alias in g.aliases:
|
|
460
|
+
grp._group_alias_map[alias] = gname
|
|
461
|
+
|
|
462
|
+
self._groups[prefix] = grp
|
|
463
|
+
|
|
464
|
+
# Mount resources with prefix
|
|
465
|
+
for uri, res in other._resources.items():
|
|
466
|
+
prefixed_uri = f"{prefix}/{uri}"
|
|
467
|
+
self._resources[prefixed_uri] = ResourceDef(
|
|
468
|
+
uri=prefixed_uri,
|
|
469
|
+
name=res.name,
|
|
470
|
+
description=res.description,
|
|
471
|
+
handler=res.handler,
|
|
472
|
+
mime_type=res.mime_type,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Mount prompts with prefix
|
|
476
|
+
for pname, p in other._prompts.items():
|
|
477
|
+
prefixed_name = f"{prefix}.{pname}"
|
|
478
|
+
self._prompts[prefixed_name] = PromptDef(
|
|
479
|
+
name=prefixed_name,
|
|
480
|
+
description=p.description,
|
|
481
|
+
handler=p.handler,
|
|
482
|
+
arguments=p.arguments,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
@property
|
|
486
|
+
def commands(self) -> dict[str, CommandDef | LazyCommandDef]:
|
|
487
|
+
"""All registered top-level commands (eager and lazy)."""
|
|
488
|
+
return dict(self._commands)
|
|
489
|
+
|
|
490
|
+
@property
|
|
491
|
+
def groups(self) -> dict[str, Group]:
|
|
492
|
+
"""All registered top-level groups."""
|
|
493
|
+
return dict(self._groups)
|
|
494
|
+
|
|
495
|
+
def get_command(self, name: str) -> CommandDef | LazyCommandDef | None:
|
|
496
|
+
"""Look up a command by name, alias, or dotted path.
|
|
497
|
+
|
|
498
|
+
Dotted paths traverse groups: ``get_command("site.build")``
|
|
499
|
+
resolves to the ``build`` command inside the ``site`` group.
|
|
500
|
+
"""
|
|
501
|
+
# Dotted path: walk into groups
|
|
502
|
+
if "." in name:
|
|
503
|
+
return self._resolve_dotted(name)
|
|
504
|
+
|
|
505
|
+
# Top-level command
|
|
506
|
+
if name in self._commands:
|
|
507
|
+
return self._commands[name]
|
|
508
|
+
resolved = self._alias_map.get(name)
|
|
509
|
+
if resolved:
|
|
510
|
+
return self._commands.get(resolved)
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
def _resolve_dotted(self, path: str) -> CommandDef | LazyCommandDef | None:
|
|
514
|
+
"""Resolve a dotted command path like 'site.config.show'."""
|
|
515
|
+
parts = path.split(".")
|
|
516
|
+
# Walk groups for all but the last part
|
|
517
|
+
current_group: Group | None = None
|
|
518
|
+
for part in parts[:-1]:
|
|
519
|
+
if current_group is None:
|
|
520
|
+
current_group = self._groups.get(part)
|
|
521
|
+
if current_group is None:
|
|
522
|
+
resolved = self._group_alias_map.get(part)
|
|
523
|
+
if resolved:
|
|
524
|
+
current_group = self._groups.get(resolved)
|
|
525
|
+
else:
|
|
526
|
+
current_group = current_group.get_group(part)
|
|
527
|
+
if current_group is None:
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
# Resolve the final part as a command
|
|
531
|
+
cmd_name = parts[-1]
|
|
532
|
+
if current_group is None:
|
|
533
|
+
return self.get_command(cmd_name)
|
|
534
|
+
return current_group.get_command(cmd_name)
|
|
535
|
+
|
|
536
|
+
def walk_commands(self) -> list[tuple[str, CommandDef | LazyCommandDef]]:
|
|
537
|
+
"""Walk all commands in the tree, yielding (dotted_path, CommandDef).
|
|
538
|
+
|
|
539
|
+
Top-level commands have simple names. Group commands use dots::
|
|
540
|
+
|
|
541
|
+
[("greet", greet_cmd), ("site.build", build_cmd), ...]
|
|
542
|
+
"""
|
|
543
|
+
result = [(cmd.name, cmd) for cmd in self._commands.values()]
|
|
544
|
+
for group in self._groups.values():
|
|
545
|
+
result.extend(group.walk_commands())
|
|
546
|
+
return result
|
|
547
|
+
|
|
548
|
+
def walk_resources(self) -> list[tuple[str, ResourceDef]]:
|
|
549
|
+
"""Walk all registered resources."""
|
|
550
|
+
return list(self._resources.items())
|
|
551
|
+
|
|
552
|
+
def walk_prompts(self) -> list[tuple[str, PromptDef]]:
|
|
553
|
+
"""Walk all registered prompts."""
|
|
554
|
+
return list(self._prompts.items())
|
|
555
|
+
|
|
556
|
+
def build_parser(self) -> argparse.ArgumentParser:
|
|
557
|
+
"""Build argparse parser from registered commands and groups."""
|
|
558
|
+
parser = argparse.ArgumentParser(
|
|
559
|
+
prog=self.name,
|
|
560
|
+
description=self.description,
|
|
561
|
+
)
|
|
562
|
+
if self.version:
|
|
563
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {self.version}")
|
|
564
|
+
parser.add_argument(
|
|
565
|
+
"--llms-txt",
|
|
566
|
+
action="store_true",
|
|
567
|
+
help="Output llms.txt for AI agent discovery",
|
|
568
|
+
)
|
|
569
|
+
parser.add_argument(
|
|
570
|
+
"--mcp",
|
|
571
|
+
action="store_true",
|
|
572
|
+
help="Run as MCP server (JSON-RPC on stdin/stdout)",
|
|
573
|
+
)
|
|
574
|
+
parser.add_argument(
|
|
575
|
+
"--mcp-install",
|
|
576
|
+
action="store_true",
|
|
577
|
+
help="Register this CLI in the milo gateway for AI agent discovery",
|
|
578
|
+
)
|
|
579
|
+
parser.add_argument(
|
|
580
|
+
"--mcp-uninstall",
|
|
581
|
+
action="store_true",
|
|
582
|
+
help="Remove this CLI from the milo gateway",
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# Built-in global options
|
|
586
|
+
parser.add_argument(
|
|
587
|
+
"-v",
|
|
588
|
+
"--verbose",
|
|
589
|
+
action="count",
|
|
590
|
+
default=0,
|
|
591
|
+
help="Increase verbosity (-v verbose, -vv debug)",
|
|
592
|
+
)
|
|
593
|
+
parser.add_argument(
|
|
594
|
+
"-q",
|
|
595
|
+
"--quiet",
|
|
596
|
+
action="store_true",
|
|
597
|
+
default=False,
|
|
598
|
+
help="Suppress non-error output",
|
|
599
|
+
)
|
|
600
|
+
parser.add_argument(
|
|
601
|
+
"--no-color",
|
|
602
|
+
action="store_true",
|
|
603
|
+
default=False,
|
|
604
|
+
help="Disable color output",
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
# User-defined global options
|
|
608
|
+
for opt in self._global_options:
|
|
609
|
+
flags = [f"--{opt.name.replace('_', '-')}"]
|
|
610
|
+
if opt.short:
|
|
611
|
+
flags.insert(0, opt.short)
|
|
612
|
+
kwargs: dict[str, Any] = {
|
|
613
|
+
"dest": opt.name,
|
|
614
|
+
"help": opt.description,
|
|
615
|
+
"default": opt.default,
|
|
616
|
+
}
|
|
617
|
+
if opt.is_flag:
|
|
618
|
+
kwargs["action"] = "store_true"
|
|
619
|
+
else:
|
|
620
|
+
kwargs["type"] = opt.option_type
|
|
621
|
+
parser.add_argument(*flags, **kwargs)
|
|
622
|
+
|
|
623
|
+
has_children = self._commands or self._groups
|
|
624
|
+
if has_children:
|
|
625
|
+
subparsers = parser.add_subparsers(dest="_command")
|
|
626
|
+
self._add_commands_to_subparsers(subparsers, self._commands)
|
|
627
|
+
self._add_groups_to_subparsers(subparsers, self._groups)
|
|
628
|
+
|
|
629
|
+
return parser
|
|
630
|
+
|
|
631
|
+
def _add_commands_to_subparsers(
|
|
632
|
+
self,
|
|
633
|
+
subparsers: argparse._SubParsersAction,
|
|
634
|
+
commands: dict[str, CommandDef | LazyCommandDef],
|
|
635
|
+
) -> None:
|
|
636
|
+
"""Add command parsers to a subparsers action."""
|
|
637
|
+
for cmd in commands.values():
|
|
638
|
+
if cmd.hidden:
|
|
639
|
+
continue
|
|
640
|
+
sub = subparsers.add_parser(
|
|
641
|
+
cmd.name,
|
|
642
|
+
help=cmd.description,
|
|
643
|
+
aliases=list(cmd.aliases),
|
|
644
|
+
)
|
|
645
|
+
self._add_arguments_from_schema(sub, cmd.schema, cmd)
|
|
646
|
+
sub.add_argument(
|
|
647
|
+
"--format",
|
|
648
|
+
choices=["plain", "json", "table"],
|
|
649
|
+
default="plain",
|
|
650
|
+
help="Output format (default: plain)",
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def _add_groups_to_subparsers(
|
|
654
|
+
self,
|
|
655
|
+
subparsers: argparse._SubParsersAction,
|
|
656
|
+
groups: dict[str, Group],
|
|
657
|
+
) -> None:
|
|
658
|
+
"""Recursively add group parsers to a subparsers action."""
|
|
659
|
+
for group in groups.values():
|
|
660
|
+
if group.hidden:
|
|
661
|
+
continue
|
|
662
|
+
group_parser = subparsers.add_parser(
|
|
663
|
+
group.name,
|
|
664
|
+
help=group.description,
|
|
665
|
+
aliases=list(group.aliases),
|
|
666
|
+
)
|
|
667
|
+
has_children = group._commands or group._groups
|
|
668
|
+
if has_children:
|
|
669
|
+
group_sub = group_parser.add_subparsers(dest=f"_command_{group.name}")
|
|
670
|
+
self._add_commands_to_subparsers(group_sub, group._commands)
|
|
671
|
+
self._add_groups_to_subparsers(group_sub, group._groups)
|
|
672
|
+
|
|
673
|
+
def _add_arguments_from_schema(
|
|
674
|
+
self,
|
|
675
|
+
parser: argparse.ArgumentParser,
|
|
676
|
+
schema: dict[str, Any],
|
|
677
|
+
cmd: CommandDef | LazyCommandDef,
|
|
678
|
+
) -> None:
|
|
679
|
+
"""Add argparse arguments from a command's JSON schema.
|
|
680
|
+
|
|
681
|
+
Uses the handler's signature for defaults when available (eager
|
|
682
|
+
commands), or falls back to schema-only mode (lazy commands).
|
|
683
|
+
"""
|
|
684
|
+
props = schema.get("properties", {})
|
|
685
|
+
required_set = set(schema.get("required", []))
|
|
686
|
+
|
|
687
|
+
# Try to get signature for defaults (only for eager commands)
|
|
688
|
+
sig = None
|
|
689
|
+
if isinstance(cmd, CommandDef):
|
|
690
|
+
sig = inspect.signature(cmd.handler)
|
|
691
|
+
|
|
692
|
+
for param_name, param_schema in props.items():
|
|
693
|
+
param = sig.parameters.get(param_name) if sig else None
|
|
694
|
+
kwargs: dict[str, Any] = {}
|
|
695
|
+
|
|
696
|
+
# Determine type
|
|
697
|
+
json_type = param_schema.get("type", "string")
|
|
698
|
+
if json_type == "boolean":
|
|
699
|
+
default = (
|
|
700
|
+
param.default
|
|
701
|
+
if param and param.default is not inspect.Parameter.empty
|
|
702
|
+
else False
|
|
703
|
+
)
|
|
704
|
+
kwargs["action"] = "store_true"
|
|
705
|
+
kwargs["default"] = default
|
|
706
|
+
elif json_type == "integer":
|
|
707
|
+
kwargs["type"] = int
|
|
708
|
+
elif json_type == "number":
|
|
709
|
+
kwargs["type"] = float
|
|
710
|
+
elif json_type == "array":
|
|
711
|
+
kwargs["nargs"] = "*"
|
|
712
|
+
item_type = param_schema.get("items", {}).get("type", "string")
|
|
713
|
+
if item_type == "integer":
|
|
714
|
+
kwargs["type"] = int
|
|
715
|
+
elif item_type == "number":
|
|
716
|
+
kwargs["type"] = float
|
|
717
|
+
else:
|
|
718
|
+
kwargs["type"] = str
|
|
719
|
+
|
|
720
|
+
# Set default from signature if available
|
|
721
|
+
if param and param.default is not inspect.Parameter.empty and json_type != "boolean":
|
|
722
|
+
kwargs["default"] = param.default
|
|
723
|
+
|
|
724
|
+
# Required vs optional
|
|
725
|
+
if param_name in required_set and json_type != "boolean":
|
|
726
|
+
kwargs["required"] = True
|
|
727
|
+
|
|
728
|
+
flag = f"--{param_name.replace('_', '-')}"
|
|
729
|
+
parser.add_argument(flag, dest=param_name, **kwargs)
|
|
730
|
+
|
|
731
|
+
def run(self, argv: list[str] | None = None) -> Any:
|
|
732
|
+
"""Parse args and dispatch to the appropriate command."""
|
|
733
|
+
parser = self.build_parser()
|
|
734
|
+
args = parser.parse_args(argv)
|
|
735
|
+
|
|
736
|
+
# --llms-txt mode
|
|
737
|
+
if getattr(args, "llms_txt", False):
|
|
738
|
+
from milo.llms import generate_llms_txt
|
|
739
|
+
|
|
740
|
+
sys.stdout.write(generate_llms_txt(self))
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
# --mcp mode
|
|
744
|
+
if getattr(args, "mcp", False):
|
|
745
|
+
from milo.mcp import run_mcp_server
|
|
746
|
+
|
|
747
|
+
run_mcp_server(self)
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
# --mcp-install mode
|
|
751
|
+
if getattr(args, "mcp_install", False):
|
|
752
|
+
self._mcp_install()
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
# --mcp-uninstall mode
|
|
756
|
+
if getattr(args, "mcp_uninstall", False):
|
|
757
|
+
self._mcp_uninstall()
|
|
758
|
+
return None
|
|
759
|
+
|
|
760
|
+
# Build execution context from global options
|
|
761
|
+
ctx = self._build_context(args)
|
|
762
|
+
|
|
763
|
+
# Resolve command from args (may be nested in groups)
|
|
764
|
+
found, fmt = self._resolve_command_from_args(args)
|
|
765
|
+
if not found:
|
|
766
|
+
parser.print_help()
|
|
767
|
+
return None
|
|
768
|
+
|
|
769
|
+
# Resolve lazy commands
|
|
770
|
+
cmd = found.resolve() if isinstance(found, LazyCommandDef) else found
|
|
771
|
+
|
|
772
|
+
# Extract command arguments and inject context
|
|
773
|
+
sig = inspect.signature(cmd.handler)
|
|
774
|
+
kwargs = {}
|
|
775
|
+
for param_name, param in sig.parameters.items():
|
|
776
|
+
if param_name == "ctx" or _is_context_param(param):
|
|
777
|
+
kwargs[param_name] = ctx
|
|
778
|
+
elif hasattr(args, param_name):
|
|
779
|
+
kwargs[param_name] = getattr(args, param_name)
|
|
780
|
+
|
|
781
|
+
# Set context for get_context() access
|
|
782
|
+
from milo.context import set_context
|
|
783
|
+
|
|
784
|
+
set_context(ctx)
|
|
785
|
+
|
|
786
|
+
# Call handler (through middleware if present)
|
|
787
|
+
result = cmd.handler(**kwargs)
|
|
788
|
+
|
|
789
|
+
# Handle streaming generators
|
|
790
|
+
from milo.streaming import consume_generator, is_generator_result
|
|
791
|
+
|
|
792
|
+
if is_generator_result(result):
|
|
793
|
+
progress_list, final_value = consume_generator(result)
|
|
794
|
+
for p in progress_list:
|
|
795
|
+
sys.stderr.write(f" {p.status}\n")
|
|
796
|
+
result = final_value
|
|
797
|
+
|
|
798
|
+
# Format and output
|
|
799
|
+
write_output(result, fmt=fmt)
|
|
800
|
+
|
|
801
|
+
return result
|
|
802
|
+
|
|
803
|
+
def _build_context(self, args: argparse.Namespace) -> Context:
|
|
804
|
+
"""Build a Context from parsed global options."""
|
|
805
|
+
from milo.context import Context as ContextClass
|
|
806
|
+
|
|
807
|
+
verbose = getattr(args, "verbose", 0)
|
|
808
|
+
quiet = getattr(args, "quiet", False)
|
|
809
|
+
verbosity = -1 if quiet else verbose
|
|
810
|
+
|
|
811
|
+
# Collect user global option values
|
|
812
|
+
user_globals = {}
|
|
813
|
+
for opt in self._global_options:
|
|
814
|
+
if hasattr(args, opt.name):
|
|
815
|
+
user_globals[opt.name] = getattr(args, opt.name)
|
|
816
|
+
|
|
817
|
+
return ContextClass(
|
|
818
|
+
verbosity=verbosity,
|
|
819
|
+
format=getattr(args, "format", "plain"),
|
|
820
|
+
color=not getattr(args, "no_color", False),
|
|
821
|
+
globals=user_globals,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
def _resolve_command_from_args(
|
|
825
|
+
self, args: argparse.Namespace
|
|
826
|
+
) -> tuple[CommandDef | LazyCommandDef | None, str]:
|
|
827
|
+
"""Walk the parsed args to find the leaf command."""
|
|
828
|
+
fmt = getattr(args, "format", "plain")
|
|
829
|
+
|
|
830
|
+
# Check top-level command
|
|
831
|
+
cmd_name = getattr(args, "_command", None)
|
|
832
|
+
if not cmd_name:
|
|
833
|
+
return None, fmt
|
|
834
|
+
|
|
835
|
+
# Is it a direct command?
|
|
836
|
+
cmd = self.get_command(cmd_name)
|
|
837
|
+
if cmd:
|
|
838
|
+
return cmd, fmt
|
|
839
|
+
|
|
840
|
+
# Is it a group? Walk into it.
|
|
841
|
+
group = self._groups.get(cmd_name)
|
|
842
|
+
if group is None:
|
|
843
|
+
resolved = self._group_alias_map.get(cmd_name)
|
|
844
|
+
if resolved:
|
|
845
|
+
group = self._groups.get(resolved)
|
|
846
|
+
if group is None:
|
|
847
|
+
return None, fmt
|
|
848
|
+
|
|
849
|
+
return self._resolve_group_command(group, args, fmt)
|
|
850
|
+
|
|
851
|
+
def _resolve_group_command(
|
|
852
|
+
self,
|
|
853
|
+
group: Group,
|
|
854
|
+
args: argparse.Namespace,
|
|
855
|
+
fmt: str,
|
|
856
|
+
) -> tuple[CommandDef | None, str]:
|
|
857
|
+
"""Recursively resolve a command within a group from parsed args."""
|
|
858
|
+
sub_name = getattr(args, f"_command_{group.name}", None)
|
|
859
|
+
if not sub_name:
|
|
860
|
+
return None, fmt
|
|
861
|
+
|
|
862
|
+
# Check if it's a command in this group
|
|
863
|
+
cmd = group.get_command(sub_name)
|
|
864
|
+
if cmd:
|
|
865
|
+
return cmd, fmt
|
|
866
|
+
|
|
867
|
+
# Check if it's a nested sub-group
|
|
868
|
+
sub_group = group.get_group(sub_name)
|
|
869
|
+
if sub_group:
|
|
870
|
+
return self._resolve_group_command(sub_group, args, fmt)
|
|
871
|
+
|
|
872
|
+
return None, fmt
|
|
873
|
+
|
|
874
|
+
def call(self, command_name: str, **kwargs: Any) -> Any:
|
|
875
|
+
"""Programmatically call a command by name or dotted path.
|
|
876
|
+
|
|
877
|
+
Used by MCP server and for programmatic invocation::
|
|
878
|
+
|
|
879
|
+
cli.call("greet", name="Alice")
|
|
880
|
+
cli.call("site.build", output="_site")
|
|
881
|
+
"""
|
|
882
|
+
found = self.get_command(command_name)
|
|
883
|
+
if not found:
|
|
884
|
+
suggestion = self.suggest_command(command_name)
|
|
885
|
+
msg = f"Unknown command: {command_name!r}"
|
|
886
|
+
if suggestion:
|
|
887
|
+
msg += f". Did you mean {suggestion!r}?"
|
|
888
|
+
raise ValueError(msg)
|
|
889
|
+
|
|
890
|
+
# Resolve lazy commands
|
|
891
|
+
cmd = found.resolve() if isinstance(found, LazyCommandDef) else found
|
|
892
|
+
|
|
893
|
+
sig = inspect.signature(cmd.handler)
|
|
894
|
+
# Filter to only valid parameters (exclude context params)
|
|
895
|
+
valid = {
|
|
896
|
+
k: v
|
|
897
|
+
for k, v in kwargs.items()
|
|
898
|
+
if k in sig.parameters and not _is_context_param(sig.parameters[k])
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
result = cmd.handler(**valid)
|
|
902
|
+
|
|
903
|
+
# Handle streaming generators
|
|
904
|
+
from milo.streaming import consume_generator, is_generator_result
|
|
905
|
+
|
|
906
|
+
if is_generator_result(result):
|
|
907
|
+
_, result = consume_generator(result)
|
|
908
|
+
|
|
909
|
+
return result
|
|
910
|
+
|
|
911
|
+
def suggest_command(self, name: str) -> str | None:
|
|
912
|
+
"""Suggest the closest command name for typo correction."""
|
|
913
|
+
all_names = [path for path, _ in self.walk_commands()]
|
|
914
|
+
matches = difflib.get_close_matches(name, all_names, n=1, cutoff=0.6)
|
|
915
|
+
return matches[0] if matches else None
|
|
916
|
+
|
|
917
|
+
def _mcp_install(self) -> None:
|
|
918
|
+
"""Register this CLI in the milo gateway."""
|
|
919
|
+
from milo.registry import install
|
|
920
|
+
|
|
921
|
+
# Build the command to invoke this CLI with --mcp
|
|
922
|
+
# Use sys.argv[0] to get the script/module that was run
|
|
923
|
+
command = [sys.executable, sys.argv[0], "--mcp"]
|
|
924
|
+
project_root = os.getcwd()
|
|
925
|
+
|
|
926
|
+
install(
|
|
927
|
+
name=self.name,
|
|
928
|
+
command=command,
|
|
929
|
+
description=self.description,
|
|
930
|
+
version=self.version,
|
|
931
|
+
project_root=project_root,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
def _mcp_uninstall(self) -> None:
|
|
935
|
+
"""Remove this CLI from the milo gateway."""
|
|
936
|
+
from milo.registry import uninstall
|
|
937
|
+
|
|
938
|
+
uninstall(self.name)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _is_context_param(param: inspect.Parameter) -> bool:
|
|
942
|
+
"""Check if a parameter is a Context injection point."""
|
|
943
|
+
annotation = param.annotation
|
|
944
|
+
if annotation is inspect.Parameter.empty:
|
|
945
|
+
return False
|
|
946
|
+
# Check for Context type or string annotation
|
|
947
|
+
if isinstance(annotation, type):
|
|
948
|
+
return annotation.__name__ == "Context"
|
|
949
|
+
if isinstance(annotation, str):
|
|
950
|
+
return annotation in ("Context", "milo.context.Context")
|
|
951
|
+
return False
|