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/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