milo-cli 0.2.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (113) hide show
  1. {milo_cli-0.2.0 → milo_cli-0.2.1}/PKG-INFO +1 -1
  2. {milo_cli-0.2.0 → milo_cli-0.2.1}/pyproject.toml +2 -2
  3. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/__init__.py +1 -1
  4. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_command_defs.py +8 -0
  5. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/commands.py +149 -41
  6. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/groups.py +42 -0
  7. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/help.py +2 -0
  8. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/schema.py +10 -0
  9. milo_cli-0.2.1/src/milo/templates/help.kida +30 -0
  10. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/PKG-INFO +1 -1
  11. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_effects.py +9 -9
  12. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_groups.py +139 -0
  13. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_lazy.py +280 -0
  14. milo_cli-0.2.0/src/milo/templates/help.kida +0 -13
  15. {milo_cli-0.2.0 → milo_cli-0.2.1}/README.md +0 -0
  16. {milo_cli-0.2.0 → milo_cli-0.2.1}/setup.cfg +0 -0
  17. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_child.py +0 -0
  18. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_cli_help.py +0 -0
  19. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_compat.py +0 -0
  20. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_errors.py +0 -0
  21. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_jsonrpc.py +0 -0
  22. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_mcp_router.py +0 -0
  23. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_protocols.py +0 -0
  24. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_types.py +0 -0
  25. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/app.py +0 -0
  26. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/cli.py +0 -0
  27. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/completions.py +0 -0
  28. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/config.py +0 -0
  29. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/context.py +0 -0
  30. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/dev.py +0 -0
  31. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/doctor.py +0 -0
  32. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/flow.py +0 -0
  33. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/form.py +0 -0
  34. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/gateway.py +0 -0
  35. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/input/__init__.py +0 -0
  36. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/input/_platform.py +0 -0
  37. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/input/_reader.py +0 -0
  38. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/input/_sequences.py +0 -0
  39. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/llms.py +0 -0
  40. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/mcp.py +0 -0
  41. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/middleware.py +0 -0
  42. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/observability.py +0 -0
  43. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/output.py +0 -0
  44. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/pipeline.py +0 -0
  45. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/plugins.py +0 -0
  46. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/py.typed +0 -0
  47. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/reducers.py +0 -0
  48. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/registry.py +0 -0
  49. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/state.py +0 -0
  50. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/streaming.py +0 -0
  51. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/__init__.py +0 -0
  52. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/components/_defs.kida +0 -0
  53. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/components/command_list.kida +0 -0
  54. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/components/help_page.kida +0 -0
  55. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/error.kida +0 -0
  56. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/field_confirm.kida +0 -0
  57. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/field_select.kida +0 -0
  58. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/field_text.kida +0 -0
  59. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/form.kida +0 -0
  60. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/progress.kida +0 -0
  61. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/__init__.py +0 -0
  62. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/_mcp.py +0 -0
  63. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/_record.py +0 -0
  64. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/_replay.py +0 -0
  65. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/_snapshot.py +0 -0
  66. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/theme.py +0 -0
  67. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/version_check.py +0 -0
  68. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/SOURCES.txt +0 -0
  69. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/dependency_links.txt +0 -0
  70. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/entry_points.txt +0 -0
  71. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/requires.txt +0 -0
  72. {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/top_level.txt +0 -0
  73. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_ai_native.py +0 -0
  74. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_app.py +0 -0
  75. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_bubbletea_patterns.py +0 -0
  76. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_child.py +0 -0
  77. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_cli.py +0 -0
  78. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_commands_middleware.py +0 -0
  79. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_compat.py +0 -0
  80. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_completions.py +0 -0
  81. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_components.py +0 -0
  82. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_config.py +0 -0
  83. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_context.py +0 -0
  84. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_dev.py +0 -0
  85. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_doctor.py +0 -0
  86. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_effects_stress.py +0 -0
  87. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_errors.py +0 -0
  88. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_flow.py +0 -0
  89. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_form.py +0 -0
  90. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_gateway.py +0 -0
  91. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_help.py +0 -0
  92. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_input.py +0 -0
  93. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_mcp_prompts.py +0 -0
  94. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_mcp_resources.py +0 -0
  95. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_mcp_router.py +0 -0
  96. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_middleware.py +0 -0
  97. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_milo_init.py +0 -0
  98. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_mount.py +0 -0
  99. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_observability.py +0 -0
  100. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_pipeline.py +0 -0
  101. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_plugins.py +0 -0
  102. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_protocols.py +0 -0
  103. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_reducers.py +0 -0
  104. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_registry_v2.py +0 -0
  105. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_schema_v2.py +0 -0
  106. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_state.py +0 -0
  107. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_streaming.py +0 -0
  108. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_templates.py +0 -0
  109. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_testing.py +0 -0
  110. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_testing_mcp.py +0 -0
  111. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_theme.py +0 -0
  112. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_types.py +0 -0
  113. {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: milo-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Template-driven CLI applications for free-threaded Python
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://lbliii.github.io/milo-cli/
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "milo-cli"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Template-driven CLI applications for free-threaded Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -136,7 +136,7 @@ directory = "changelog.d"
136
136
  filename = "CHANGELOG.md"
137
137
  package = "milo"
138
138
  package_dir = "src"
139
- title_format = "## [{version}] - {project_date}"
139
+ title_format = "## {version} {project_date}"
140
140
  issue_format = "[#{issue}](https://github.com/lbliii/milo-cli/issues/{issue})"
141
141
  underlines = ["", "", ""]
142
142
 
@@ -154,7 +154,7 @@ def _Py_mod_gil() -> int: # noqa: N802
154
154
  return 0
155
155
 
156
156
 
157
- __version__ = "0.2.0"
157
+ __version__ = "0.2.1"
158
158
  __all__ = [
159
159
  "BUILTIN_ACTIONS",
160
160
  "CLI",
@@ -59,6 +59,8 @@ class CommandDef:
59
59
  """If non-empty, prompt for confirmation before running."""
60
60
  annotations: dict[str, Any] = field(default_factory=dict)
61
61
  """MCP tool annotations (readOnlyHint, destructiveHint, etc.)."""
62
+ display_result: bool = True
63
+ """If False, suppress plain-format output (return value still available for --format json)."""
62
64
 
63
65
 
64
66
  class LazyCommandDef:
@@ -80,6 +82,7 @@ class LazyCommandDef:
80
82
  "annotations",
81
83
  "confirm",
82
84
  "description",
85
+ "display_result",
83
86
  "examples",
84
87
  "hidden",
85
88
  "import_path",
@@ -100,6 +103,7 @@ class LazyCommandDef:
100
103
  examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
101
104
  confirm: str = "",
102
105
  annotations: dict[str, Any] | None = None,
106
+ display_result: bool = True,
103
107
  ) -> None:
104
108
  self.name = name
105
109
  self.description = description
@@ -110,6 +114,7 @@ class LazyCommandDef:
110
114
  self.examples = tuple(examples)
111
115
  self.confirm = confirm
112
116
  self.annotations = annotations or {}
117
+ self.display_result = display_result
113
118
  self._schema = schema
114
119
  self._resolved: CommandDef | None = None
115
120
  self._lock = threading.Lock()
@@ -159,6 +164,7 @@ class LazyCommandDef:
159
164
  examples=self.examples,
160
165
  confirm=self.confirm,
161
166
  annotations=self.annotations,
167
+ display_result=self.display_result,
162
168
  )
163
169
  return self._resolved
164
170
 
@@ -185,6 +191,7 @@ def _make_command_def(
185
191
  examples: tuple[dict[str, Any], ...] = (),
186
192
  confirm: str = "",
187
193
  annotations: dict[str, Any] | None = None,
194
+ display_result: bool = True,
188
195
  ) -> CommandDef:
189
196
  """Build a CommandDef from a function and decorator kwargs."""
190
197
  from milo.schema import function_to_schema
@@ -204,6 +211,7 @@ def _make_command_def(
204
211
  examples=examples,
205
212
  confirm=confirm,
206
213
  annotations=annotations or {},
214
+ display_result=display_result,
207
215
  )
208
216
 
209
217
 
@@ -9,6 +9,7 @@ import io
9
9
  import os
10
10
  import sys
11
11
  from collections.abc import Callable
12
+ from dataclasses import dataclass
12
13
  from typing import TYPE_CHECKING, Any, NoReturn
13
14
 
14
15
  from milo._command_defs import (
@@ -41,6 +42,39 @@ __all__ = [
41
42
  ]
42
43
 
43
44
 
45
+ # ---------------------------------------------------------------------------
46
+ # Resolve result types — discriminated union for command resolution
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ResolvedCommand:
52
+ """A command was found and should be dispatched."""
53
+
54
+ command: CommandDef | LazyCommandDef
55
+ fmt: str
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ResolvedGroup:
60
+ """A group was invoked without a subcommand — show its help."""
61
+
62
+ group: Group
63
+ fmt: str
64
+ prog: str = ""
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class ResolvedNothing:
69
+ """No command or group matched — may offer did-you-mean."""
70
+
71
+ attempted: str | None
72
+ fmt: str
73
+
74
+
75
+ ResolveResult = ResolvedCommand | ResolvedGroup | ResolvedNothing
76
+
77
+
44
78
  class _MiloArgumentParser(argparse.ArgumentParser):
45
79
  """ArgumentParser subclass that provides did-you-mean suggestions."""
46
80
 
@@ -186,6 +220,7 @@ class CLI:
186
220
  examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
187
221
  confirm: str = "",
188
222
  annotations: dict[str, Any] | None = None,
223
+ display_result: bool = True,
189
224
  ) -> Callable:
190
225
  """Register a function as a CLI command.
191
226
 
@@ -198,6 +233,8 @@ class CLI:
198
233
  confirm: If set, prompt user with this message before executing.
199
234
  annotations: MCP tool annotations (readOnlyHint, destructiveHint,
200
235
  idempotentHint, openWorldHint).
236
+ display_result: If False, suppress plain-format output while still
237
+ returning data for ``--format json`` or ``--output-file``.
201
238
  """
202
239
 
203
240
  def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@@ -211,6 +248,7 @@ class CLI:
211
248
  examples=tuple(examples),
212
249
  confirm=confirm,
213
250
  annotations=annotations,
251
+ display_result=display_result,
214
252
  )
215
253
  self._commands[name] = cmd
216
254
  for alias in aliases:
@@ -233,11 +271,16 @@ class CLI:
233
271
  examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
234
272
  confirm: str = "",
235
273
  annotations: dict[str, Any] | None = None,
274
+ display_result: bool = True,
236
275
  ) -> LazyCommandDef:
237
276
  """Register a lazy-loaded command.
238
277
 
239
278
  The handler module is not imported until the command is invoked.
240
279
  This keeps CLI startup fast for large command sets.
280
+
281
+ When providing a pre-computed *schema*, include ``"default"`` fields
282
+ in properties for optional parameters so argparse receives the correct
283
+ defaults without importing the handler module.
241
284
  """
242
285
  cmd = LazyCommandDef(
243
286
  name=name,
@@ -250,6 +293,7 @@ class CLI:
250
293
  examples=examples,
251
294
  confirm=confirm,
252
295
  annotations=annotations,
296
+ display_result=display_result,
253
297
  )
254
298
  self._commands[name] = cmd
255
299
  for alias in aliases:
@@ -620,7 +664,7 @@ class CLI:
620
664
  "--format",
621
665
  choices=["plain", "json", "table"],
622
666
  default="plain",
623
- help="Output format (default: plain)",
667
+ help="Output format",
624
668
  )
625
669
 
626
670
  def _add_groups_to_subparsers(
@@ -670,13 +714,21 @@ class CLI:
670
714
  # Determine type
671
715
  json_type = param_schema.get("type", "string")
672
716
  if json_type == "boolean":
673
- default = (
674
- param.default
675
- if param and param.default is not inspect.Parameter.empty
676
- else False
677
- )
678
- kwargs["action"] = "store_true"
717
+ if param and param.default is not inspect.Parameter.empty:
718
+ default = param.default
719
+ elif "default" in param_schema:
720
+ default = param_schema["default"]
721
+ else:
722
+ default = False
679
723
  kwargs["default"] = default
724
+ if default is True:
725
+ # default=True → --no-xxx flag to disable
726
+ flag = f"--no-{param_name.replace('_', '-')}"
727
+ kwargs["action"] = "store_false"
728
+ parser.add_argument(flag, dest=param_name, **kwargs)
729
+ continue
730
+ else:
731
+ kwargs["action"] = "store_true"
680
732
  elif json_type == "integer":
681
733
  kwargs["type"] = int
682
734
  elif json_type == "number":
@@ -691,9 +743,15 @@ class CLI:
691
743
  else:
692
744
  kwargs["type"] = str
693
745
 
694
- # Set default from signature if available
746
+ # Enum choices from schema
747
+ if "enum" in param_schema:
748
+ kwargs["choices"] = param_schema["enum"]
749
+
750
+ # Set default from signature or schema
695
751
  if param and param.default is not inspect.Parameter.empty and json_type != "boolean":
696
752
  kwargs["default"] = param.default
753
+ elif "default" not in kwargs and "default" in param_schema:
754
+ kwargs["default"] = param_schema["default"]
697
755
 
698
756
  # Required vs optional
699
757
  if param_name in required_set and json_type != "boolean":
@@ -710,6 +768,7 @@ class CLI:
710
768
  def run(self, argv: list[str] | None = None) -> Any:
711
769
  """Parse args and dispatch to the appropriate command."""
712
770
  parser = self.build_parser()
771
+ self._parser = parser
713
772
  args = parser.parse_args(argv)
714
773
 
715
774
  # --completions mode
@@ -747,20 +806,26 @@ class CLI:
747
806
  ctx = self._build_context(args)
748
807
 
749
808
  # Resolve command from args (may be nested in groups)
750
- found, fmt = self._resolve_command_from_args(args)
751
- if not found:
752
- # Did-you-mean suggestion for typos
753
- cmd_name = getattr(args, "_command", None)
754
- if cmd_name:
755
- suggestion = self.suggest_command(cmd_name)
809
+ result = self._resolve_command_from_args(args)
810
+
811
+ if isinstance(result, ResolvedGroup):
812
+ result.group.format_help(result.prog)
813
+ return None
814
+
815
+ if isinstance(result, ResolvedNothing):
816
+ if result.attempted:
817
+ suggestion = self.suggest_command(result.attempted)
756
818
  if suggestion:
757
819
  sys.stderr.write(
758
- f"Unknown command: {cmd_name!r}. Did you mean {suggestion!r}?\n"
820
+ f"Unknown command: {result.attempted!r}. Did you mean {suggestion!r}?\n"
759
821
  )
760
822
  return None
761
- parser.print_help()
823
+ self._format_root_help()
762
824
  return None
763
825
 
826
+ found = result.command
827
+ fmt = result.fmt
828
+
764
829
  # Resolve lazy commands
765
830
  cmd = found.resolve() if isinstance(found, LazyCommandDef) else found
766
831
 
@@ -838,13 +903,17 @@ class CLI:
838
903
  ctx.error(f"after_command hook failed: {type(exc).__name__}: {exc}")
839
904
 
840
905
  # Format and output (to file or stdout)
841
- output_file = ctx.output_file
842
- if output_file:
843
- formatted = format_output(result, fmt=fmt)
844
- with open(output_file, "w") as f:
845
- f.write(formatted + "\n")
846
- else:
847
- write_output(result, fmt=fmt)
906
+ # When display_result=False, suppress plain-format stdout output but
907
+ # still honor explicit --format or --output-file requests.
908
+ suppress = not cmd.display_result and fmt == "plain" and not ctx.output_file
909
+ if not suppress:
910
+ output_file = ctx.output_file
911
+ if output_file:
912
+ formatted = format_output(result, fmt=fmt)
913
+ with open(output_file, "w") as f:
914
+ f.write(formatted + "\n")
915
+ else:
916
+ write_output(result, fmt=fmt)
848
917
 
849
918
  return result
850
919
 
@@ -914,55 +983,94 @@ class CLI:
914
983
  globals=user_globals,
915
984
  )
916
985
 
917
- def _resolve_command_from_args(
918
- self, args: argparse.Namespace
919
- ) -> tuple[CommandDef | LazyCommandDef | None, str]:
920
- """Walk the parsed args to find the leaf command."""
986
+ def _format_root_help(self) -> None:
987
+ """Render root help from command/group registries and the actual parser."""
988
+ from milo.help import HelpState
989
+ from milo.templates import get_env
990
+
991
+ commands = tuple(
992
+ [
993
+ {"name": cmd.name, "help": getattr(cmd, "description", "")}
994
+ for cmd in self._commands.values()
995
+ if not getattr(cmd, "hidden", False)
996
+ ]
997
+ + [
998
+ {"name": g.name, "help": g.description}
999
+ for g in self._groups.values()
1000
+ if not g.hidden
1001
+ ]
1002
+ )
1003
+
1004
+ # Derive options from the actual parser so new flags are never missed
1005
+ options: list[dict[str, Any]] = []
1006
+ for action in self._parser._actions:
1007
+ if isinstance(action, argparse._SubParsersAction):
1008
+ continue
1009
+ flags = ", ".join(action.option_strings) if action.option_strings else ""
1010
+ if not flags:
1011
+ continue
1012
+ entry: dict[str, Any] = {"flags": flags, "help": action.help or ""}
1013
+ if action.metavar:
1014
+ entry["metavar"] = action.metavar
1015
+ options.append(entry)
1016
+
1017
+ state = HelpState(
1018
+ prog=self.name,
1019
+ description=self.description,
1020
+ commands=commands,
1021
+ options=tuple(options),
1022
+ )
1023
+ env = get_env()
1024
+ template = env.get_template("help.kida")
1025
+ sys.stdout.write(template.render(state=state) + "\n")
1026
+ sys.stdout.flush()
1027
+
1028
+ def _resolve_command_from_args(self, args: argparse.Namespace) -> ResolveResult:
1029
+ """Walk the parsed args to find the leaf command, group, or nothing."""
921
1030
  fmt = getattr(args, "format", "plain")
922
1031
 
923
- # Check top-level command
924
1032
  cmd_name = getattr(args, "_command", None)
925
1033
  if not cmd_name:
926
- return None, fmt
1034
+ return ResolvedNothing(attempted=None, fmt=fmt)
927
1035
 
928
- # Is it a direct command?
1036
+ # Direct command?
929
1037
  cmd = self.get_command(cmd_name)
930
1038
  if cmd:
931
- return cmd, fmt
1039
+ return ResolvedCommand(command=cmd, fmt=fmt)
932
1040
 
933
- # Is it a group? Walk into it.
1041
+ # Group? Walk into it.
934
1042
  group = self._groups.get(cmd_name)
935
1043
  if group is None:
936
1044
  resolved = self._group_alias_map.get(cmd_name)
937
1045
  if resolved:
938
1046
  group = self._groups.get(resolved)
939
1047
  if group is None:
940
- return None, fmt
1048
+ return ResolvedNothing(attempted=cmd_name, fmt=fmt)
941
1049
 
942
- return self._resolve_group_command(group, args, fmt)
1050
+ return self._resolve_group_command(group, args, fmt, prog=self.name)
943
1051
 
944
1052
  def _resolve_group_command(
945
1053
  self,
946
1054
  group: Group,
947
1055
  args: argparse.Namespace,
948
1056
  fmt: str,
949
- ) -> tuple[CommandDef | None, str]:
1057
+ prog: str = "",
1058
+ ) -> ResolveResult:
950
1059
  """Recursively resolve a command within a group from parsed args."""
1060
+ group_prog = f"{prog} {group.name}".strip()
951
1061
  sub_name = getattr(args, f"_command_{group.name}", None)
952
1062
  if not sub_name:
953
- return None, fmt
1063
+ return ResolvedGroup(group=group, fmt=fmt, prog=group_prog)
954
1064
 
955
- # Check if it's a command in this group
956
1065
  cmd = group.get_command(sub_name)
957
1066
  if cmd:
958
- return cmd, fmt
1067
+ return ResolvedCommand(command=cmd, fmt=fmt)
959
1068
 
960
- # Check if it's a nested sub-group
961
1069
  sub_group = group.get_group(sub_name)
962
1070
  if sub_group:
963
- return self._resolve_group_command(sub_group, args, fmt)
1071
+ return self._resolve_group_command(sub_group, args, fmt, prog=group_prog)
964
1072
 
965
- return None, fmt
1073
+ return ResolvedNothing(attempted=sub_name, fmt=fmt)
966
1074
 
967
1075
  def call(self, command_name: str, **kwargs: Any) -> Any:
968
1076
  """Programmatically call a command by name or dotted path.
@@ -67,6 +67,7 @@ class Group:
67
67
  hidden: bool = False,
68
68
  examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
69
69
  confirm: str = "",
70
+ display_result: bool = True,
70
71
  ) -> Callable:
71
72
  """Register a function as a command within this group."""
72
73
  from milo.commands import _make_command_def
@@ -81,6 +82,7 @@ class Group:
81
82
  hidden=hidden,
82
83
  examples=tuple(examples),
83
84
  confirm=confirm,
85
+ display_result=display_result,
84
86
  )
85
87
  self._commands[name] = cmd
86
88
  for alias in aliases:
@@ -100,6 +102,10 @@ class Group:
100
102
  aliases: tuple[str, ...] | list[str] = (),
101
103
  tags: tuple[str, ...] | list[str] = (),
102
104
  hidden: bool = False,
105
+ examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
106
+ confirm: str = "",
107
+ annotations: dict[str, Any] | None = None,
108
+ display_result: bool = True,
103
109
  ) -> Any:
104
110
  """Register a lazy-loaded command within this group.
105
111
 
@@ -115,6 +121,10 @@ class Group:
115
121
  aliases=aliases,
116
122
  tags=tags,
117
123
  hidden=hidden,
124
+ examples=examples,
125
+ confirm=confirm,
126
+ annotations=annotations,
127
+ display_result=display_result,
118
128
  )
119
129
  self._commands[name] = cmd
120
130
  for alias in aliases:
@@ -181,6 +191,38 @@ class Group:
181
191
  hidden=self.hidden,
182
192
  )
183
193
 
194
+ def format_help(self, prog_prefix: str = "") -> str:
195
+ """Render help from this group's command/group registries.
196
+
197
+ Writes rendered help to stdout and returns the output string.
198
+ """
199
+ import sys
200
+
201
+ from milo.help import HelpState
202
+ from milo.templates import get_env
203
+
204
+ prog = f"{prog_prefix} {self.name}".strip() if prog_prefix else self.name
205
+ commands = tuple(
206
+ [
207
+ {"name": cmd.name, "help": getattr(cmd, "description", "")}
208
+ for cmd in self._commands.values()
209
+ if not getattr(cmd, "hidden", False)
210
+ ]
211
+ + [
212
+ {"name": g.name, "help": g.description}
213
+ for g in self._groups.values()
214
+ if not g.hidden
215
+ ]
216
+ )
217
+
218
+ state = HelpState(prog=prog, description=self.description, commands=commands)
219
+ env = get_env()
220
+ template = env.get_template("help.kida")
221
+ output = template.render(state=state)
222
+ sys.stdout.write(output + "\n")
223
+ sys.stdout.flush()
224
+ return output
225
+
184
226
  def walk_commands(self, prefix: str = ""):
185
227
  """Yield (dotted_path, CommandDef) for all commands in this tree."""
186
228
  path_prefix = f"{prefix}{self.name}." if prefix else f"{self.name}."
@@ -17,6 +17,8 @@ class HelpState:
17
17
  usage: str = ""
18
18
  groups: tuple[dict[str, Any], ...] = ()
19
19
  examples: tuple[dict[str, Any], ...] = ()
20
+ commands: tuple[dict[str, Any], ...] = ()
21
+ options: tuple[dict[str, Any], ...] = ()
20
22
 
21
23
 
22
24
  class HelpRenderer(argparse.HelpFormatter):
@@ -6,6 +6,7 @@ import dataclasses
6
6
  import enum
7
7
  import functools
8
8
  import inspect
9
+ import json
9
10
  import re as _re
10
11
  import types
11
12
  import typing
@@ -161,6 +162,15 @@ def function_to_schema(func: Callable[..., Any]) -> dict[str, Any]:
161
162
  properties[name] = prop
162
163
 
163
164
  has_default = param.default is not inspect.Parameter.empty
165
+ if has_default and isinstance(
166
+ param.default, (str, int, float, bool, type(None), list, dict)
167
+ ):
168
+ try:
169
+ json.dumps(param.default)
170
+ except TypeError, ValueError:
171
+ pass # non-serializable nested value — omit default
172
+ else:
173
+ prop["default"] = param.default
164
174
  if not has_default and not is_optional:
165
175
  required.append(name)
166
176
 
@@ -0,0 +1,30 @@
1
+ {{ state.prog | bold }}{% if state.description %} {{ "-" | dim }} {{ state.description }}{% endif %}
2
+ {%- if state.commands %}
3
+
4
+ {{ "commands" | yellow | bold }}:
5
+ {%- for cmd in state.commands %}
6
+ {{ cmd.name | cyan }} {{ cmd.help }}
7
+ {%- endfor %}
8
+ {%- endif %}
9
+ {%- for group in state.groups %}
10
+
11
+ {{ group.title | yellow | bold }}:
12
+ {%- for action in group.actions %}
13
+ {% if action.option_strings %}{{ action.option_strings | join(", ") | green }}{% if action.metavar %} {{ action.metavar | dim }}{% endif %} {{ action.help }}{% if action.default is not none and action.default != "==SUPPRESS==" and action.default is not false and action.default != 0 and action.default != "" %} {{ ("(default: " ~ action.default ~ ")") | dim }}{% endif %}{% else %}{{ action.dest | cyan }}{% if action.metavar %} {{ action.metavar | dim }}{% endif %} {{ action.help }}{% if action.default is not none and action.default != "==SUPPRESS==" and action.default is not false and action.default != 0 and action.default != "" %} {{ ("(default: " ~ action.default ~ ")") | dim }}{% endif %}{% endif %}
14
+ {%- endfor %}
15
+ {%- endfor %}
16
+ {%- if state.options %}
17
+
18
+ {{ "options" | yellow | bold }}:
19
+ {%- for opt in state.options %}
20
+ {{ opt.flags | green }}{% if opt.metavar %} {{ opt.metavar | dim }}{% endif %} {{ opt.help }}{% if opt.default %} {{ ("(default: " ~ opt.default ~ ")") | dim }}{% endif %}
21
+ {%- endfor %}
22
+ {%- endif %}
23
+ {%- if state.examples %}
24
+
25
+ {{ "examples" | yellow | bold }}:
26
+ {%- for ex in state.examples %}
27
+ {{ ("$ " ~ ex.command) | green }}{% if ex.description %}
28
+ {{ ex.description | dim }}{% endif %}
29
+ {%- endfor %}
30
+ {%- endif %}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: milo-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Template-driven CLI applications for free-threaded Python
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://lbliii.github.io/milo-cli/
@@ -992,14 +992,14 @@ class TestDebounceEffect:
992
992
  yield Put(Action("DEBOUNCED_FIRE"))
993
993
 
994
994
  def parent():
995
- # First debounce — 0.15s
996
- yield Debounce(seconds=0.15, saga=inner_saga)
995
+ # First debounce — 0.2s
996
+ yield Debounce(seconds=0.2, saga=inner_saga)
997
997
  # Wait a bit, then retrigger before first fires
998
998
  yield Delay(seconds=0.05)
999
999
  # Second debounce — resets the timer
1000
- yield Debounce(seconds=0.15, saga=inner_saga)
1000
+ yield Debounce(seconds=0.2, saga=inner_saga)
1001
1001
  # Wait long enough for second to fire
1002
- yield Delay(seconds=0.3)
1002
+ yield Delay(seconds=0.5)
1003
1003
 
1004
1004
  def reducer(state, action):
1005
1005
  actions.append(action.type)
@@ -1007,7 +1007,7 @@ class TestDebounceEffect:
1007
1007
 
1008
1008
  store = Store(reducer, None)
1009
1009
  store.run_saga(parent())
1010
- time.sleep(0.6)
1010
+ time.sleep(1.0)
1011
1011
  store._executor.shutdown(wait=True)
1012
1012
 
1013
1013
  # Should fire exactly once (first timer cancelled, second fires)
@@ -1460,10 +1460,10 @@ class TestEffectComposition:
1460
1460
  def parent():
1461
1461
  # Simulate: receive 3 rapid keys, debounce should fire once
1462
1462
  for _ in range(3):
1463
- yield Take("@@KEY", timeout=1.0)
1464
- yield Debounce(seconds=0.15, saga=search_saga)
1463
+ yield Take("@@KEY", timeout=2.0)
1464
+ yield Debounce(seconds=0.5, saga=search_saga)
1465
1465
  # Wait for debounce to fire
1466
- yield Delay(seconds=0.3)
1466
+ yield Delay(seconds=1.0)
1467
1467
 
1468
1468
  def reducer(state, action):
1469
1469
  actions.append(action.type)
@@ -1478,7 +1478,7 @@ class TestEffectComposition:
1478
1478
  store.dispatch(Action("@@KEY", payload="b"))
1479
1479
  time.sleep(0.02)
1480
1480
  store.dispatch(Action("@@KEY", payload="c"))
1481
- time.sleep(0.6)
1481
+ time.sleep(1.5)
1482
1482
  store._executor.shutdown(wait=True)
1483
1483
 
1484
1484
  # Debounce should have fired exactly once (last one)