usecli 0.1.31__tar.gz → 0.1.33__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 (69) hide show
  1. {usecli-0.1.31 → usecli-0.1.33}/PKG-INFO +1 -1
  2. {usecli-0.1.31 → usecli-0.1.33}/pyproject.toml +1 -1
  3. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/__init__.py +35 -0
  4. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +26 -3
  5. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/init_command.py +35 -1
  6. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/config/colors.py +8 -2
  7. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/base_command.py +76 -1
  8. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/ui/list.py +50 -24
  9. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/templates/command.py.j2 +2 -2
  10. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/shared/config/globals.py +1 -0
  11. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/shared/config/manager.py +89 -7
  12. {usecli-0.1.31 → usecli-0.1.33}/LICENSE +0 -0
  13. {usecli-0.1.31 → usecli-0.1.33}/README.md +0 -0
  14. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/__init__.py +0 -0
  15. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/README.md +0 -0
  16. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/__init__.py +0 -0
  17. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/custom/README.md +0 -0
  18. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/custom/__init__.py +0 -0
  19. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/__init__.py +0 -0
  20. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
  21. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
  22. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
  23. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
  24. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
  25. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
  26. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
  27. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
  28. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
  29. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
  30. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/config/__init__.py +0 -0
  31. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/__init__.py +0 -0
  32. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/error/__init__.py +0 -0
  33. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/error/handler.py +0 -0
  34. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/error/utils.py +0 -0
  35. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/exceptions/__init__.py +0 -0
  36. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/exceptions/base.py +0 -0
  37. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/exceptions/config.py +0 -0
  38. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/exceptions/usage.py +0 -0
  39. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/exceptions/validation.py +0 -0
  40. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/skill_generator.py +0 -0
  41. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/ui/__init__.py +0 -0
  42. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/ui/title.py +0 -0
  43. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/validators/__init__.py +0 -0
  44. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/validators/network.py +0 -0
  45. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/validators/numeric.py +0 -0
  46. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/validators/path.py +0 -0
  47. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/core/validators/string.py +0 -0
  48. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/services/__init__.py +0 -0
  49. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/services/command_service.py +0 -0
  50. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/templates/theme.toml.j2 +0 -0
  51. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/templates/usecli.toml.j2 +0 -0
  52. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/ayu_dark.toml +0 -0
  53. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
  54. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
  55. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
  56. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
  57. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/default.toml +0 -0
  58. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/dracula.toml +0 -0
  59. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
  60. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/nord.toml +0 -0
  61. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/themes/tokyo_night.toml +0 -0
  62. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/utils/__init__.py +0 -0
  63. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/utils/interactive/__init__.py +0 -0
  64. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
  65. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/menu.py +0 -0
  66. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/params.py +0 -0
  67. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/shared/__init__.py +0 -0
  68. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/shared/config/__init__.py +0 -0
  69. {usecli-0.1.31 → usecli-0.1.33}/src/usecli/ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usecli
3
- Version: 0.1.31
3
+ Version: 0.1.33
4
4
  Summary: A powerful Python CLI framework for building beautiful, developer-friendly command-line tools.
5
5
  Author: Edward Boswell
6
6
  Author-email: Edward Boswell <thememium@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usecli"
3
- version = "0.1.31"
3
+ version = "0.1.33"
4
4
  description = "A powerful Python CLI framework for building beautiful, developer-friendly command-line tools."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Edward Boswell", email = "thememium@gmail.com" }]
@@ -91,6 +91,20 @@ def _get_cli_help_text() -> str:
91
91
  return f"{display_name} - {display_description}"
92
92
 
93
93
 
94
+ def _get_group_alias_registry(app: typer.Typer) -> dict[str, list[str]]:
95
+ registry = getattr(app, "_usecli_group_aliases", {})
96
+ return registry if isinstance(registry, dict) else {}
97
+
98
+
99
+ def _build_alias_to_primary(alias_registry: dict[str, list[str]]) -> dict[str, str]:
100
+ alias_to_primary: dict[str, str] = {}
101
+ for primary, aliases in alias_registry.items():
102
+ alias_to_primary[primary] = primary
103
+ for alias in aliases:
104
+ alias_to_primary[alias] = primary
105
+ return alias_to_primary
106
+
107
+
94
108
  class PrefixMatchingGroup(TyperGroup):
95
109
  """Custom Typer group that supports prefix matching for commands.
96
110
 
@@ -111,12 +125,33 @@ class PrefixMatchingGroup(TyperGroup):
111
125
  if rv is not None:
112
126
  return rv
113
127
 
128
+ group_alias_registry = _get_group_alias_registry(app)
129
+ group_alias_to_primary = _build_alias_to_primary(group_alias_registry)
130
+
131
+ if (
132
+ cmd_name in group_alias_to_primary
133
+ and group_alias_to_primary[cmd_name] != cmd_name
134
+ ):
135
+ return TyperGroup.get_command(self, ctx, group_alias_to_primary[cmd_name])
136
+
114
137
  matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
138
+ group_aliases = [
139
+ alias for aliases in group_alias_registry.values() for alias in aliases
140
+ ]
141
+ matches.extend([alias for alias in group_aliases if alias.startswith(cmd_name)])
142
+ matches = list(dict.fromkeys(matches))
115
143
 
116
144
  if not matches:
117
145
  return None
118
146
 
119
147
  if cmd_name in matches:
148
+ if (
149
+ cmd_name in group_alias_to_primary
150
+ and group_alias_to_primary[cmd_name] != cmd_name
151
+ ):
152
+ return TyperGroup.get_command(
153
+ self, ctx, group_alias_to_primary[cmd_name]
154
+ )
120
155
  return TyperGroup.get_command(self, ctx, cmd_name)
121
156
 
122
157
  return FilteredListCommand(cmd_name)
@@ -155,11 +155,27 @@ def _run_fzf_menu(
155
155
  )
156
156
 
157
157
 
158
- def _get_group_subcommands(group_name: str) -> list[dict[str, Any]]:
158
+ def _resolve_group_alias(app: typer.Typer, group_name: str) -> str:
159
+ registry = getattr(app, "_usecli_group_aliases", {})
160
+ if not isinstance(registry, dict):
161
+ return group_name
162
+ for primary, aliases in registry.items():
163
+ if group_name == primary:
164
+ return primary
165
+ if group_name in aliases:
166
+ return primary
167
+ return group_name
168
+
169
+
170
+ def _get_group_subcommands(
171
+ app: typer.Typer,
172
+ group_name: str,
173
+ ) -> list[dict[str, Any]]:
159
174
  from usecli.cli.core.base_command import NestedCommandRegistry
160
175
 
161
176
  registry = NestedCommandRegistry()
162
- group_app = registry._groups.get(group_name)
177
+ resolved_group = _resolve_group_alias(app, group_name)
178
+ group_app = registry._groups.get(resolved_group)
163
179
 
164
180
  if not group_app:
165
181
  return []
@@ -260,12 +276,19 @@ def run_interactive(
260
276
  selected_cmd = next(
261
277
  (c for c in ordered_commands if c["name"] == cmd_name), None
262
278
  )
279
+ if not selected_cmd:
280
+ resolved_group = _resolve_group_alias(app, cmd_name)
281
+ if resolved_group != cmd_name:
282
+ cmd_name = resolved_group
283
+ selected_cmd = next(
284
+ (c for c in ordered_commands if c["name"] == cmd_name), None
285
+ )
263
286
  if not selected_cmd:
264
287
  ErrorHandler.display_error(f"Unknown command '{cmd_name}'")
265
288
  raise typer.Exit(code=1)
266
289
 
267
290
  if selected_cmd and selected_cmd.get("is_group"):
268
- subcommands = _get_group_subcommands(cmd_name)
291
+ subcommands = _get_group_subcommands(app, cmd_name)
269
292
  if not subcommands:
270
293
  ErrorHandler.display_error(f"No subcommands found for '{cmd_name}'")
271
294
  raise typer.Exit(code=1)
@@ -27,7 +27,7 @@ from usecli.cli.core.base_command import BaseCommand
27
27
  from usecli.cli.core.exceptions import UsecliBadParameter
28
28
  from usecli.cli.core.validators import validate_command_name
29
29
  from usecli.cli.utils.interactive.terminal_menu import terminal_menu
30
- from usecli.shared.config.globals import TEMPLATES_DIR, THEMES_DIR
30
+ from usecli.shared.config.globals import TEMPLATES_DIR, THEMES_DIR, USECLI_TOML
31
31
  from usecli.shared.config.manager import ConfigManager, get_config
32
32
 
33
33
  console = Console()
@@ -113,6 +113,23 @@ class InitCommand(BaseCommand):
113
113
  pyproject_path.write_text(content)
114
114
  return True
115
115
 
116
+ def _write_usecli_toml(
117
+ self, project_root: Path, config_content: str, force: bool
118
+ ) -> str:
119
+ config_path = project_root / USECLI_TOML
120
+ existed = config_path.exists()
121
+ if existed and not force:
122
+ should_overwrite = Confirm.ask(
123
+ f"[{COLOR.WARNING}]usecli.toml already exists at {config_path}.[/{COLOR.WARNING}]\n"
124
+ "Overwrite it with the new settings from this init run?",
125
+ default=False,
126
+ )
127
+ if not should_overwrite:
128
+ return "skipped"
129
+
130
+ config_path.write_text(config_content.rstrip() + "\n")
131
+ return "updated" if existed else "created"
132
+
116
133
  def _ensure_project_scripts(
117
134
  self, pyproject_path: Path, command_name: str, force: bool
118
135
  ) -> str:
@@ -685,6 +702,7 @@ include = ["{root_package}*"]
685
702
  )
686
703
 
687
704
  scripts_status: str | None = None
705
+ usecli_toml_status: str | None = None
688
706
 
689
707
  # Check if pyproject.toml exists
690
708
  if pyproject_path.exists():
@@ -744,6 +762,22 @@ include = ["{root_package}*"]
744
762
 
745
763
  self._sync_environment(project_root, command_name)
746
764
 
765
+ usecli_toml_status = self._write_usecli_toml(
766
+ project_root, config_content, force
767
+ )
768
+ if usecli_toml_status == "created":
769
+ console.print(
770
+ f"[{COLOR.SUCCESS}]Created {USECLI_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
771
+ )
772
+ elif usecli_toml_status == "updated":
773
+ console.print(
774
+ f"[{COLOR.SUCCESS}]Updated {USECLI_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
775
+ )
776
+ elif usecli_toml_status == "skipped":
777
+ console.print(
778
+ f"[{COLOR.WARNING}]Skipped updating {USECLI_TOML}.[/{COLOR.WARNING}]"
779
+ )
780
+
747
781
  # Show summary
748
782
  summary_command = (
749
783
  command_name
@@ -21,6 +21,8 @@ else:
21
21
  import tomli as tomllib
22
22
 
23
23
 
24
+ PYPROJECT_TOML = "pyproject.toml"
25
+ USECLI_TOML = "usecli.toml"
24
26
  DEFAULT_THEME_NAME = "default"
25
27
  THEMES_DIR = Path(__file__).resolve().parent.parent / "themes"
26
28
  DEFAULT_THEME_COLORS: dict[str, str] = {
@@ -53,10 +55,14 @@ def _find_project_root(start_dir: Path | None = None) -> Path | None:
53
55
  current = start_dir.resolve()
54
56
 
55
57
  while True:
56
- pyproject_path = current / "pyproject.toml"
58
+ pyproject_path = current / PYPROJECT_TOML
57
59
  if pyproject_path.exists():
58
60
  return current
59
61
 
62
+ usecli_path = current / USECLI_TOML
63
+ if usecli_path.exists():
64
+ return current
65
+
60
66
  git_dir = current / ".git"
61
67
  if git_dir.exists():
62
68
  return current
@@ -73,7 +79,7 @@ def _load_usecli_config(project_root: Path | None) -> dict[str, Any]:
73
79
  if project_root is None:
74
80
  return {}
75
81
 
76
- pyproject_path = project_root / "pyproject.toml"
82
+ pyproject_path = project_root / PYPROJECT_TOML
77
83
  if not pyproject_path.exists():
78
84
  return {}
79
85
 
@@ -344,7 +344,7 @@ class BaseCommand(ABC):
344
344
  registry = NestedCommandRegistry()
345
345
  group_app = registry.get_or_create_group(self.app, group_name)
346
346
 
347
- normalized_aliases = self._normalize_aliases(
347
+ normalized_aliases, group_aliases = self._normalize_nested_aliases(
348
348
  primary_name=command_name,
349
349
  aliases=aliases,
350
350
  group_name=group_name,
@@ -355,6 +355,11 @@ class BaseCommand(ABC):
355
355
  description=self.description(),
356
356
  aliases=normalized_aliases,
357
357
  )
358
+ self._register_group_aliases(
359
+ app=self.app,
360
+ group_name=group_name,
361
+ aliases=group_aliases,
362
+ )
358
363
  else:
359
364
  # Single-level command (e.g., "help", "init", "config:set", "make:command")
360
365
  name = signature_parts[0]
@@ -409,6 +414,31 @@ class BaseCommand(ABC):
409
414
  setattr(app, "_usecli_aliases", registry)
410
415
  return registry
411
416
 
417
+ def _get_group_alias_registry(self, app: typer.Typer) -> dict[str, list[str]]:
418
+ if not hasattr(app, "_usecli_group_aliases"):
419
+ setattr(app, "_usecli_group_aliases", {})
420
+ registry = getattr(app, "_usecli_group_aliases")
421
+ if not isinstance(registry, dict):
422
+ registry = {}
423
+ setattr(app, "_usecli_group_aliases", registry)
424
+ return registry
425
+
426
+ def _register_group_aliases(
427
+ self,
428
+ app: typer.Typer,
429
+ group_name: str,
430
+ aliases: list[str],
431
+ ) -> None:
432
+ if not aliases:
433
+ return
434
+
435
+ registry = self._get_group_alias_registry(app)
436
+ registry.setdefault(group_name, [])
437
+ for alias in aliases:
438
+ if alias == group_name or alias in registry[group_name]:
439
+ continue
440
+ registry[group_name].append(alias)
441
+
412
442
  def _normalize_aliases(
413
443
  self,
414
444
  primary_name: str,
@@ -437,6 +467,51 @@ class BaseCommand(ABC):
437
467
  normalized.append(alias_name)
438
468
  return normalized
439
469
 
470
+ def _normalize_nested_aliases(
471
+ self,
472
+ primary_name: str,
473
+ aliases: list[str],
474
+ group_name: str,
475
+ ) -> tuple[list[str], list[str]]:
476
+ normalized: list[str] = []
477
+ group_aliases: list[str] = []
478
+
479
+ for alias in aliases:
480
+ alias_parts = alias.split()
481
+ if len(alias_parts) == 1:
482
+ alias_name = alias_parts[0]
483
+ if not self._is_valid_subcommand_name(alias_name):
484
+ continue
485
+ if alias_name == primary_name or alias_name in normalized:
486
+ continue
487
+ normalized.append(alias_name)
488
+ continue
489
+
490
+ if len(alias_parts) != 2:
491
+ continue
492
+
493
+ group_alias, alias_name = alias_parts
494
+
495
+ if not self._is_valid_subcommand_name(group_alias):
496
+ continue
497
+ if not self._is_valid_subcommand_name(alias_name):
498
+ continue
499
+
500
+ if group_alias == group_name:
501
+ if alias_name == primary_name or alias_name in normalized:
502
+ continue
503
+ normalized.append(alias_name)
504
+ continue
505
+
506
+ if group_alias not in group_aliases:
507
+ group_aliases.append(group_alias)
508
+
509
+ if alias_name == primary_name or alias_name in normalized:
510
+ continue
511
+ normalized.append(alias_name)
512
+
513
+ return normalized, group_aliases
514
+
440
515
  def _is_valid_subcommand_name(self, name: str) -> bool:
441
516
  """Check if a string is a valid subcommand name (not an argument placeholder).
442
517
 
@@ -52,6 +52,8 @@ def list_commands(app: typer.Typer, prefix_filter: str | None = None) -> None:
52
52
 
53
53
  alias_registry = _get_alias_registry(app)
54
54
  alias_to_primary = _build_alias_to_primary(alias_registry)
55
+ group_alias_registry = _get_group_alias_registry(app)
56
+ group_alias_to_primary = _build_alias_to_primary(group_alias_registry)
55
57
 
56
58
  command_name = get_script_command_name(default="usecli")
57
59
 
@@ -84,23 +86,15 @@ def list_commands(app: typer.Typer, prefix_filter: str | None = None) -> None:
84
86
  commands.append(command_entry)
85
87
  commands.sort(key=lambda x: x["name"])
86
88
 
87
- if prefix_filter:
88
- filtered = [
89
- c
90
- for c in commands
91
- if c["name"].startswith(prefix_filter)
92
- or any(alias.startswith(prefix_filter) for alias in c["aliases"])
93
- ]
94
- if not filtered:
95
- console.print(f" [dim]No commands found for '{prefix_filter}'[/dim]")
96
- console.print()
97
- return
98
- commands = filtered
99
-
100
89
  groups: dict[str, str] = {}
101
90
  if isinstance(click_group, click.Group):
102
91
  for cmd_name, cmd_obj in click_group.commands.items():
103
92
  if isinstance(cmd_obj, click.Group):
93
+ if (
94
+ cmd_name in group_alias_to_primary
95
+ and group_alias_to_primary[cmd_name] != cmd_name
96
+ ):
97
+ continue
104
98
  groups[cmd_name] = (
105
99
  getattr(cmd_obj, "help", f"Commands for {cmd_name}")
106
100
  or f"Commands for {cmd_name}"
@@ -117,7 +111,41 @@ def list_commands(app: typer.Typer, prefix_filter: str | None = None) -> None:
117
111
 
118
112
  help_flags = "--help, -h"
119
113
  all_option_flags = [help_flags] + option_flags
120
- all_display_names = [cmd["display_name"] for cmd in commands] + list(groups.keys())
114
+ group_entries: list[CommandEntry] = []
115
+ for group_name, group_help in groups.items():
116
+ aliases = group_alias_registry.get(group_name, list[str]())
117
+ group_entries.append(
118
+ {
119
+ "name": group_name,
120
+ "display_name": _format_display_name(group_name, aliases),
121
+ "help": group_help,
122
+ "aliases": aliases,
123
+ }
124
+ )
125
+
126
+ if prefix_filter:
127
+ filtered = [
128
+ c
129
+ for c in commands
130
+ if c["name"].startswith(prefix_filter)
131
+ or any(alias.startswith(prefix_filter) for alias in c["aliases"])
132
+ ]
133
+ filtered_groups = [
134
+ g
135
+ for g in group_entries
136
+ if g["name"].startswith(prefix_filter)
137
+ or any(alias.startswith(prefix_filter) for alias in g["aliases"])
138
+ ]
139
+ if not filtered and not filtered_groups:
140
+ console.print(f" [dim]No commands found for '{prefix_filter}'[/dim]")
141
+ console.print()
142
+ return
143
+ commands = filtered
144
+ group_entries = filtered_groups
145
+
146
+ all_display_names = [cmd["display_name"] for cmd in commands] + [
147
+ entry["display_name"] for entry in group_entries
148
+ ]
121
149
  all_labels = all_display_names + all_option_flags
122
150
  longest_label_length = max((len(label) for label in all_labels), default=0)
123
151
 
@@ -147,8 +175,9 @@ def list_commands(app: typer.Typer, prefix_filter: str | None = None) -> None:
147
175
  if not prefix_filter:
148
176
  console.print(f"[bold {COLOR.SECONDARY}]Available commands:")
149
177
 
178
+ group_names = set(groups.keys())
150
179
  top_level: list[CommandEntry] = [
151
- c for c in commands if ":" not in c["name"] and c["name"] not in groups
180
+ c for c in commands if ":" not in c["name"] and c["name"] not in group_names
152
181
  ]
153
182
  with_colon: list[CommandEntry] = [c for c in commands if ":" in c["name"]]
154
183
 
@@ -160,15 +189,7 @@ def list_commands(app: typer.Typer, prefix_filter: str | None = None) -> None:
160
189
  f" [{COLOR.COMMAND} not bold]{display_name}[/{COLOR.COMMAND} not bold]{padding}{cmd['help']}"
161
190
  )
162
191
 
163
- for group_name, group_help in groups.items():
164
- top_level.append(
165
- {
166
- "name": group_name,
167
- "display_name": group_name,
168
- "help": group_help,
169
- "aliases": [],
170
- }
171
- )
192
+ top_level.extend(group_entries)
172
193
 
173
194
  top_level.sort(key=lambda x: x["name"])
174
195
 
@@ -291,6 +312,11 @@ def _get_alias_registry(app: typer.Typer) -> dict[str, list[str]]:
291
312
  return registry if isinstance(registry, dict) else {}
292
313
 
293
314
 
315
+ def _get_group_alias_registry(app: typer.Typer) -> dict[str, list[str]]:
316
+ registry = getattr(app, "_usecli_group_aliases", {})
317
+ return registry if isinstance(registry, dict) else {}
318
+
319
+
294
320
  def _get_option_description(param: click.Parameter) -> str:
295
321
  if "--show-completion" in param.opts:
296
322
  return "Show completion for the current shell."
@@ -12,8 +12,8 @@ class {{ class_name }}(BaseCommand):
12
12
  def description(self) -> str:
13
13
  return "Description for {{ command_name }} command"
14
14
 
15
- def aliases(self) -> list[str]:
16
- return [{{ command_name[:3] }}]
15
+ # def aliases(self) -> list[str]:
16
+ # return [{{ command_name[:3] }}]
17
17
 
18
18
  def handle(
19
19
  self,
@@ -17,3 +17,4 @@ THEMES_DIR = CLI_ROOT / "themes"
17
17
 
18
18
  # Config file names
19
19
  PYPROJECT_TOML = "pyproject.toml"
20
+ USECLI_TOML = "usecli.toml"
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  from typing import Any
13
13
 
14
14
  from usecli.cli.core.exceptions.config import UsecliConfigError
15
+ from usecli.shared.config.globals import PYPROJECT_TOML, USECLI_TOML
15
16
 
16
17
  if sys.version_info >= (3, 11):
17
18
  import tomllib
@@ -87,6 +88,7 @@ class ConfigManager:
87
88
  def __init__(
88
89
  self,
89
90
  pyproject_path: Path | None = None,
91
+ usecli_toml_path: Path | None = None,
90
92
  start_dir: Path | None = None,
91
93
  ) -> None:
92
94
  """Initialize the configuration manager.
@@ -102,12 +104,21 @@ class ConfigManager:
102
104
 
103
105
  if pyproject_path is None:
104
106
  pyproject_path = self._find_pyproject_toml(start_dir) or (
105
- start_dir / "pyproject.toml"
107
+ start_dir / PYPROJECT_TOML
108
+ )
109
+
110
+ if usecli_toml_path is None:
111
+ usecli_toml_path = self._find_usecli_toml(start_dir) or (
112
+ start_dir / USECLI_TOML
106
113
  )
107
114
 
108
115
  self.pyproject_path: Path = pyproject_path
116
+ self.usecli_toml_path: Path = usecli_toml_path
109
117
  self.start_dir: Path = start_dir
110
- self.project_root: Path = find_project_root(start_dir) or start_dir.resolve()
118
+ detected_root = find_project_root(start_dir)
119
+ if detected_root is None and self.usecli_toml_path.exists():
120
+ detected_root = self.usecli_toml_path.parent
121
+ self.project_root: Path = (detected_root or start_dir).resolve()
111
122
  self._config: dict[str, Any] = {}
112
123
  self._overrides: dict[str, Any] = {}
113
124
  self._load_config()
@@ -117,18 +128,32 @@ class ConfigManager:
117
128
  self._config = self.DEFAULT_CONFIG.copy()
118
129
  self._overrides = {}
119
130
 
131
+ loaded = False
120
132
  if self.pyproject_path.exists():
121
133
  try:
122
134
  pyproject_config = self._load_pyproject_toml(self.pyproject_path)
123
135
  if pyproject_config:
124
136
  self._config = _deep_merge(self._config, pyproject_config)
125
137
  self._overrides = _deep_merge(self._overrides, pyproject_config)
138
+ loaded = True
126
139
  except (tomllib.TOMLDecodeError, OSError) as e:
127
140
  raise UsecliConfigError(
128
141
  f"Failed to load pyproject.toml: {e}",
129
142
  config_file=str(self.pyproject_path),
130
143
  ) from e
131
144
 
145
+ if not loaded and self.usecli_toml_path.exists():
146
+ try:
147
+ usecli_config = self._load_usecli_toml(self.usecli_toml_path)
148
+ if usecli_config:
149
+ self._config = _deep_merge(self._config, usecli_config)
150
+ self._overrides = _deep_merge(self._overrides, usecli_config)
151
+ except (tomllib.TOMLDecodeError, OSError) as e:
152
+ raise UsecliConfigError(
153
+ f"Failed to load usecli.toml: {e}",
154
+ config_file=str(self.usecli_toml_path),
155
+ ) from e
156
+
132
157
  default_themes = _normalize_themes_dir(self.DEFAULT_CONFIG.get("themes_dir"))
133
158
  override_themes = _normalize_themes_dir(self._overrides.get("themes_dir"))
134
159
  merged_themes = _dedupe_items(default_themes + override_themes)
@@ -151,7 +176,7 @@ class ConfigManager:
151
176
  current = start_dir.resolve()
152
177
 
153
178
  while True:
154
- pyproject_path = current / "pyproject.toml"
179
+ pyproject_path = current / PYPROJECT_TOML
155
180
  if pyproject_path.exists():
156
181
  return pyproject_path
157
182
 
@@ -162,6 +187,38 @@ class ConfigManager:
162
187
 
163
188
  return None
164
189
 
190
+ @classmethod
191
+ def _find_usecli_toml(cls, start_dir: Path) -> Path | None:
192
+ current = start_dir.resolve()
193
+
194
+ while True:
195
+ config_path = current / USECLI_TOML
196
+ if config_path.exists():
197
+ return config_path
198
+
199
+ parent = current.parent
200
+ if parent == current:
201
+ break
202
+ current = parent
203
+
204
+ return cls._find_usecli_toml_on_sys_path()
205
+
206
+ @staticmethod
207
+ def _find_usecli_toml_on_sys_path() -> Path | None:
208
+ for entry in sys.path:
209
+ if not entry:
210
+ continue
211
+ path = Path(entry)
212
+ if not path.exists() or not path.is_dir():
213
+ continue
214
+ candidate = path / USECLI_TOML
215
+ if candidate.exists():
216
+ return candidate
217
+ for child in path.glob(f"*/{USECLI_TOML}"):
218
+ if child.exists():
219
+ return child
220
+ return None
221
+
165
222
  @staticmethod
166
223
  def _load_pyproject_toml(path: Path) -> dict[str, Any]:
167
224
  """Load pyproject.toml and return [tool.usecli] section.
@@ -176,6 +233,23 @@ class ConfigManager:
176
233
  data = tomllib.load(f)
177
234
  return data.get("tool", {}).get("usecli", {})
178
235
 
236
+ @staticmethod
237
+ def _load_usecli_toml(path: Path) -> dict[str, Any]:
238
+ with open(path, "rb") as f:
239
+ data = tomllib.load(f)
240
+
241
+ tool_section = data.get("tool", {})
242
+ if isinstance(tool_section, dict):
243
+ usecli_section = tool_section.get("usecli", {})
244
+ if isinstance(usecli_section, dict):
245
+ return usecli_section
246
+
247
+ usecli_section = data.get("usecli", {})
248
+ if isinstance(usecli_section, dict):
249
+ return usecli_section
250
+
251
+ return {}
252
+
179
253
  def get(self, key: str, default: Any = None) -> Any:
180
254
  """Get a configuration value using dot notation.
181
255
 
@@ -256,9 +330,13 @@ class ConfigManager:
256
330
  @property
257
331
  def pyproject_exists(self) -> bool:
258
332
  """Check if pyproject.toml with [tool.usecli] exists."""
259
- if not self.pyproject_path.exists():
260
- return False
261
- return self._pyproject_has_usecli(self.pyproject_path)
333
+ if self.pyproject_path.exists() and self._pyproject_has_usecli(
334
+ self.pyproject_path
335
+ ):
336
+ return True
337
+ if self.usecli_toml_path.exists():
338
+ return True
339
+ return False
262
340
 
263
341
  @staticmethod
264
342
  def _load_project_version(path: Path) -> str | None:
@@ -305,10 +383,14 @@ def find_project_root(start_dir: Path | None = None) -> Path | None:
305
383
  current = start_dir.resolve()
306
384
 
307
385
  while True:
308
- pyproject_path = current / "pyproject.toml"
386
+ pyproject_path = current / PYPROJECT_TOML
309
387
  if pyproject_path.exists():
310
388
  return current
311
389
 
390
+ usecli_path = current / USECLI_TOML
391
+ if usecli_path.exists():
392
+ return current
393
+
312
394
  git_dir = current / ".git"
313
395
  if git_dir.exists():
314
396
  return current
File without changes
File without changes
File without changes
File without changes
File without changes