usecli 0.1.33__tar.gz → 0.1.35__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 (70) hide show
  1. {usecli-0.1.33 → usecli-0.1.35}/PKG-INFO +4 -2
  2. {usecli-0.1.33 → usecli-0.1.35}/README.md +3 -1
  3. {usecli-0.1.33 → usecli-0.1.35}/pyproject.toml +4 -9
  4. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/init_command.py +57 -75
  5. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/config/colors.py +59 -13
  6. usecli-0.1.33/src/usecli/cli/templates/usecli.toml.j2 → usecli-0.1.35/src/usecli/cli/templates/usecli.config.toml.j2 +1 -1
  7. usecli-0.1.35/src/usecli/cli/usecli.config.toml +13 -0
  8. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/shared/config/globals.py +1 -1
  9. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/shared/config/manager.py +73 -70
  10. {usecli-0.1.33 → usecli-0.1.35}/LICENSE +0 -0
  11. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/__init__.py +0 -0
  12. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/__init__.py +0 -0
  13. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/README.md +0 -0
  14. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/__init__.py +0 -0
  15. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/custom/README.md +0 -0
  16. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/custom/__init__.py +0 -0
  17. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/__init__.py +0 -0
  18. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
  19. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
  20. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
  21. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
  22. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
  23. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
  24. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
  25. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
  26. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
  27. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
  28. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
  29. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/config/__init__.py +0 -0
  30. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/__init__.py +0 -0
  31. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/base_command.py +0 -0
  32. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/error/__init__.py +0 -0
  33. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/error/handler.py +0 -0
  34. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/error/utils.py +0 -0
  35. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/exceptions/__init__.py +0 -0
  36. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/exceptions/base.py +0 -0
  37. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/exceptions/config.py +0 -0
  38. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/exceptions/usage.py +0 -0
  39. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/exceptions/validation.py +0 -0
  40. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/skill_generator.py +0 -0
  41. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/ui/__init__.py +0 -0
  42. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/ui/list.py +0 -0
  43. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/ui/title.py +0 -0
  44. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/validators/__init__.py +0 -0
  45. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/validators/network.py +0 -0
  46. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/validators/numeric.py +0 -0
  47. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/validators/path.py +0 -0
  48. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/core/validators/string.py +0 -0
  49. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/services/__init__.py +0 -0
  50. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/services/command_service.py +0 -0
  51. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/templates/command.py.j2 +0 -0
  52. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/templates/theme.toml.j2 +0 -0
  53. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/ayu_dark.toml +0 -0
  54. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
  55. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
  56. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
  57. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
  58. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/default.toml +0 -0
  59. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/dracula.toml +0 -0
  60. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
  61. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/nord.toml +0 -0
  62. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/themes/tokyo_night.toml +0 -0
  63. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/utils/__init__.py +0 -0
  64. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/utils/interactive/__init__.py +0 -0
  65. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
  66. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/menu.py +0 -0
  67. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/params.py +0 -0
  68. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/shared/__init__.py +0 -0
  69. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/shared/config/__init__.py +0 -0
  70. {usecli-0.1.33 → usecli-0.1.35}/src/usecli/ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usecli
3
- Version: 0.1.33
3
+ Version: 0.1.35
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>
@@ -190,8 +190,10 @@ make:command Create new command
190
190
 
191
191
  ### Hiding Built-in Commands
192
192
 
193
+ Add this to `usecli.config.toml`:
194
+
193
195
  ```toml
194
- [tool.usecli]
196
+ [usecli]
195
197
  hide_init = true
196
198
  hide_inspire = true
197
199
  ```
@@ -161,8 +161,10 @@ make:command Create new command
161
161
 
162
162
  ### Hiding Built-in Commands
163
163
 
164
+ Add this to `usecli.config.toml`:
165
+
164
166
  ```toml
165
- [tool.usecli]
167
+ [usecli]
166
168
  hide_init = true
167
169
  hide_inspire = true
168
170
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usecli"
3
- version = "0.1.33"
3
+ version = "0.1.35"
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" }]
@@ -81,11 +81,6 @@ exclude = ["**/__pycache__", "**/.venv"]
81
81
  # Ty uses rule = "level" format (error, warn, ignore)
82
82
  # See all rules at: https://docs.astral.sh/ty/reference/rules/
83
83
 
84
- [tool.usecli]
85
- theme = "default"
86
- commands_dir = "src/usecli/cli/commands/custom"
87
- templates_dir = "src/usecli/cli/templates"
88
- themes_dir = "src/usecli/cli/themes"
89
- hide_init = false
90
- hide_inspire = false
91
- hide_make_command = false
84
+ [tool.setuptools.packages.find]
85
+ where = ["."]
86
+ include = ["cli*"]
@@ -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, USECLI_TOML
30
+ from usecli.shared.config.globals import TEMPLATES_DIR, THEMES_DIR, USECLI_CONFIG_TOML
31
31
  from usecli.shared.config.manager import ConfigManager, get_config
32
32
 
33
33
  console = Console()
@@ -44,27 +44,6 @@ class InitCommand(BaseCommand):
44
44
  def description(self) -> str:
45
45
  return "Initialize usecli in the current project"
46
46
 
47
- def _replace_config_in_pyproject(
48
- self, pyproject_path: Path, config_content: str
49
- ) -> None:
50
- """Replace existing [tool.usecli] section in pyproject.toml."""
51
- content = pyproject_path.read_text()
52
-
53
- # Pattern to match [tool.usecli] section until next section or end of file
54
- pattern = r"\[tool\.usecli\].*?(?=\n\[|\Z)"
55
- replacement = config_content.rstrip() + "\n"
56
-
57
- # Replace the existing section
58
- new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
59
-
60
- pyproject_path.write_text(new_content)
61
-
62
- def _get_config_source(self, pyproject_path: Path) -> str | None:
63
- """Get the source of existing config."""
64
- if pyproject_path.exists() and "[tool.usecli]" in pyproject_path.read_text():
65
- return "pyproject.toml"
66
- return None
67
-
68
47
  def _ensure_build_system(self, pyproject_path: Path) -> bool:
69
48
  if not pyproject_path.exists():
70
49
  return False
@@ -102,25 +81,21 @@ class InitCommand(BaseCommand):
102
81
  f'include = ["{root_package}*"]\n\n'
103
82
  )
104
83
 
105
- if "[tool.usecli]" in content:
106
- content = content.replace(
107
- "[tool.usecli]",
108
- f"{discovery_block}[tool.usecli]",
109
- )
110
- else:
111
- content = content.rstrip() + f"\n\n{discovery_block}"
84
+ content = content.rstrip() + f"\n\n{discovery_block}"
112
85
 
113
86
  pyproject_path.write_text(content)
114
87
  return True
115
88
 
116
- def _write_usecli_toml(
117
- self, project_root: Path, config_content: str, force: bool
89
+ def _write_usecli_config(
90
+ self,
91
+ config_path: Path,
92
+ config_content: str,
93
+ force: bool,
118
94
  ) -> str:
119
- config_path = project_root / USECLI_TOML
120
95
  existed = config_path.exists()
121
96
  if existed and not force:
122
97
  should_overwrite = Confirm.ask(
123
- f"[{COLOR.WARNING}]usecli.toml already exists at {config_path}.[/{COLOR.WARNING}]\n"
98
+ f"[{COLOR.WARNING}]usecli.config.toml already exists at {config_path}.[/{COLOR.WARNING}]\n"
124
99
  "Overwrite it with the new settings from this init run?",
125
100
  default=False,
126
101
  )
@@ -130,6 +105,14 @@ class InitCommand(BaseCommand):
130
105
  config_path.write_text(config_content.rstrip() + "\n")
131
106
  return "updated" if existed else "created"
132
107
 
108
+ def _resolve_config_path(self, value: str, project_root: Path) -> Path:
109
+ path = Path(value).expanduser()
110
+ if not path.is_absolute():
111
+ path = project_root / path
112
+ if path.exists() and path.is_dir():
113
+ return (path / USECLI_CONFIG_TOML).resolve()
114
+ return path.resolve()
115
+
133
116
  def _ensure_project_scripts(
134
117
  self, pyproject_path: Path, command_name: str, force: bool
135
118
  ) -> str:
@@ -466,7 +449,6 @@ class InitCommand(BaseCommand):
466
449
  title: str,
467
450
  description: str,
468
451
  commands_dir: str,
469
- config_content: str,
470
452
  ) -> None:
471
453
  parts = Path(commands_dir).parts
472
454
  root_package = parts[0] if parts else "src"
@@ -493,8 +475,7 @@ build-backend = "setuptools.build_meta"
493
475
  [tool.setuptools.packages.find]
494
476
  where = ["."]
495
477
  include = ["{root_package}*"]
496
-
497
- {config_content}'''
478
+ '''
498
479
 
499
480
  pyproject_path.write_text(pyproject_content)
500
481
 
@@ -612,21 +593,6 @@ include = ["{root_package}*"]
612
593
  console.print()
613
594
  console.print(f"[{COLOR.PRIMARY}]{title_text}")
614
595
 
615
- # Check if config already exists
616
- existing_source = self._get_config_source(pyproject_path)
617
-
618
- if existing_source and not force:
619
- should_overwrite = Confirm.ask(
620
- f"[{COLOR.WARNING}]usecli config already exists in {existing_source}.[/{COLOR.WARNING}]\n"
621
- "Overwrite it with the new settings from this init run?",
622
- default=False,
623
- )
624
- if not should_overwrite:
625
- console.print(
626
- f"[{COLOR.WARNING}]Skipping config update.[/{COLOR.WARNING}]"
627
- )
628
- return
629
-
630
596
  # Create the commands directory
631
597
  if not commands_path.exists():
632
598
  commands_path.mkdir(parents=True, exist_ok=True)
@@ -686,7 +652,9 @@ include = ["{root_package}*"]
686
652
  )
687
653
 
688
654
  # Load the template
689
- template_path = Path(__file__).parent.parent / "templates" / "usecli.toml.j2"
655
+ template_path = (
656
+ Path(__file__).parent.parent / "templates" / "usecli.config.toml.j2"
657
+ )
690
658
  template_content = template_path.read_text()
691
659
  template = Template(template_content)
692
660
 
@@ -702,23 +670,10 @@ include = ["{root_package}*"]
702
670
  )
703
671
 
704
672
  scripts_status: str | None = None
705
- usecli_toml_status: str | None = None
673
+ usecli_config_status: str | None = None
706
674
 
707
675
  # Check if pyproject.toml exists
708
676
  if pyproject_path.exists():
709
- content = pyproject_path.read_text()
710
- if "[tool.usecli]" in content:
711
- self._replace_config_in_pyproject(pyproject_path, config_content)
712
- console.print(
713
- f"[{COLOR.SUCCESS}]Updated [tool.usecli] in {pyproject_path}[/{COLOR.SUCCESS}]"
714
- )
715
- else:
716
- with open(pyproject_path, "a") as f:
717
- f.write("\n\n" + config_content)
718
- console.print(
719
- f"[{COLOR.SUCCESS}]Added [tool.usecli] to {pyproject_path}[/{COLOR.SUCCESS}]"
720
- )
721
-
722
677
  scripts_status = self._ensure_project_scripts(
723
678
  pyproject_path, command_name, force
724
679
  )
@@ -753,7 +708,6 @@ include = ["{root_package}*"]
753
708
  title,
754
709
  description,
755
710
  commands_dir,
756
- config_content,
757
711
  )
758
712
  console.print(
759
713
  f"[{COLOR.SUCCESS}]Created {pyproject_path}[/{COLOR.SUCCESS}]"
@@ -762,20 +716,48 @@ include = ["{root_package}*"]
762
716
 
763
717
  self._sync_environment(project_root, command_name)
764
718
 
765
- usecli_toml_status = self._write_usecli_toml(
766
- project_root, config_content, force
719
+ config_root = project_root
720
+ if commands_path.parent != project_root:
721
+ config_root = commands_path.parent
722
+ existing_config = ConfigManager(start_dir=config_root).usecli_config_path
723
+ default_config_path = (
724
+ existing_config
725
+ if existing_config.exists()
726
+ else config_root / USECLI_CONFIG_TOML
727
+ )
728
+ config_location = Prompt.ask(
729
+ f"[bold {COLOR.SECONDARY}]Config file location[/bold {COLOR.SECONDARY}]"
730
+ " (path or directory)",
731
+ default=str(default_config_path),
732
+ )
733
+ config_path = self._resolve_config_path(config_location, project_root)
734
+ if (
735
+ existing_config.exists()
736
+ and config_path.resolve() != existing_config.resolve()
737
+ and not force
738
+ ):
739
+ replace_existing = Confirm.ask(
740
+ f"[{COLOR.WARNING}]Existing {USECLI_CONFIG_TOML} found at {existing_config}.[/{COLOR.WARNING}]\n"
741
+ "Replace it instead of writing to the new location?",
742
+ default=False,
743
+ )
744
+ if replace_existing:
745
+ config_path = existing_config
746
+
747
+ usecli_config_status = self._write_usecli_config(
748
+ config_path, config_content, force
767
749
  )
768
- if usecli_toml_status == "created":
750
+ if usecli_config_status == "created":
769
751
  console.print(
770
- f"[{COLOR.SUCCESS}]Created {USECLI_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
752
+ f"[{COLOR.SUCCESS}]Created {USECLI_CONFIG_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
771
753
  )
772
- elif usecli_toml_status == "updated":
754
+ elif usecli_config_status == "updated":
773
755
  console.print(
774
- f"[{COLOR.SUCCESS}]Updated {USECLI_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
756
+ f"[{COLOR.SUCCESS}]Updated {USECLI_CONFIG_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
775
757
  )
776
- elif usecli_toml_status == "skipped":
758
+ elif usecli_config_status == "skipped":
777
759
  console.print(
778
- f"[{COLOR.WARNING}]Skipped updating {USECLI_TOML}.[/{COLOR.WARNING}]"
760
+ f"[{COLOR.WARNING}]Skipped updating {USECLI_CONFIG_TOML}.[/{COLOR.WARNING}]"
779
761
  )
780
762
 
781
763
  # Show summary
@@ -22,7 +22,7 @@ else:
22
22
 
23
23
 
24
24
  PYPROJECT_TOML = "pyproject.toml"
25
- USECLI_TOML = "usecli.toml"
25
+ USECLI_CONFIG_TOML = "usecli.config.toml"
26
26
  DEFAULT_THEME_NAME = "default"
27
27
  THEMES_DIR = Path(__file__).resolve().parent.parent / "themes"
28
28
  DEFAULT_THEME_COLORS: dict[str, str] = {
@@ -48,55 +48,101 @@ DEFAULT_THEME_COLORS: dict[str, str] = {
48
48
  }
49
49
 
50
50
 
51
+ def _find_usecli_config_path(root_dir: Path, start_dir: Path) -> Path | None:
52
+ if not root_dir.exists() or not root_dir.is_dir():
53
+ return None
54
+
55
+ candidates = [
56
+ path
57
+ for path in root_dir.rglob(USECLI_CONFIG_TOML)
58
+ if not any(part in {".venv", "venv"} for part in path.parts)
59
+ ]
60
+ if not candidates:
61
+ return None
62
+
63
+ start_dir = start_dir.resolve()
64
+ preferred: list[Path] = []
65
+ for path in candidates:
66
+ try:
67
+ path.relative_to(start_dir)
68
+ preferred.append(path)
69
+ except ValueError:
70
+ continue
71
+
72
+ selection = preferred or candidates
73
+
74
+ def _depth_key(path: Path) -> tuple[int, str]:
75
+ try:
76
+ relative = path.relative_to(start_dir)
77
+ return (len(relative.parts), str(path))
78
+ except ValueError:
79
+ relative = path.relative_to(root_dir)
80
+ return (len(relative.parts), str(path))
81
+
82
+ selection.sort(key=_depth_key)
83
+ return selection[0]
84
+
85
+
51
86
  def _find_project_root(start_dir: Path | None = None) -> Path | None:
52
87
  if start_dir is None:
53
88
  start_dir = Path.cwd()
54
89
 
55
90
  current = start_dir.resolve()
91
+ git_root: Path | None = None
56
92
 
57
93
  while True:
58
94
  pyproject_path = current / PYPROJECT_TOML
59
95
  if pyproject_path.exists():
60
96
  return current
61
97
 
62
- usecli_path = current / USECLI_TOML
98
+ usecli_path = current / USECLI_CONFIG_TOML
63
99
  if usecli_path.exists():
64
100
  return current
65
101
 
66
102
  git_dir = current / ".git"
67
103
  if git_dir.exists():
68
- return current
104
+ git_root = current
105
+ break
69
106
 
70
107
  parent = current.parent
71
108
  if parent == current:
72
109
  break
73
110
  current = parent
74
111
 
75
- return None
112
+ search_root = git_root or start_dir.resolve()
113
+ config_match = _find_usecli_config_path(search_root, start_dir)
114
+ if config_match:
115
+ return config_match.parent
116
+
117
+ return git_root
76
118
 
77
119
 
78
120
  def _load_usecli_config(project_root: Path | None) -> dict[str, Any]:
79
121
  if project_root is None:
80
122
  return {}
81
123
 
82
- pyproject_path = project_root / PYPROJECT_TOML
83
- if not pyproject_path.exists():
124
+ config_path = project_root / USECLI_CONFIG_TOML
125
+ if not config_path.exists():
126
+ config_path = _find_usecli_config_path(project_root, project_root)
127
+ if not config_path or not config_path.exists():
84
128
  return {}
85
129
 
86
130
  try:
87
- data = tomllib.loads(pyproject_path.read_text())
131
+ data = tomllib.loads(config_path.read_text())
88
132
  except (tomllib.TOMLDecodeError, OSError):
89
133
  return {}
90
134
 
91
135
  tool = data.get("tool", {})
92
- if not isinstance(tool, dict):
93
- return {}
136
+ if isinstance(tool, dict) and "usecli" in tool:
137
+ usecli_config = tool.get("usecli")
138
+ if isinstance(usecli_config, dict):
139
+ return usecli_config
94
140
 
95
- usecli_config = tool.get("usecli", {})
96
- if not isinstance(usecli_config, dict):
97
- return {}
141
+ usecli_section = data.get("usecli", {})
142
+ if isinstance(usecli_section, dict):
143
+ return usecli_section
98
144
 
99
- return usecli_config
145
+ return {}
100
146
 
101
147
 
102
148
  def _normalize_color(value: Any) -> str | None:
@@ -1,4 +1,4 @@
1
- [tool.usecli]
1
+ [usecli]
2
2
  title = "{{ title | default('My CLI') }}"
3
3
  title_file = "{{ title_file | default('') }}"
4
4
  title_font = "{{ title_font | default('big') }}"
@@ -0,0 +1,13 @@
1
+ [usecli]
2
+ title = "usecli"
3
+ title_file = ""
4
+ title_font = "ansi_shadow"
5
+ description = "A custom CLI tool"
6
+ commands_dir = "src/usecli/cli/commands/custom"
7
+ templates_dir = "src/usecli/cli/templates"
8
+ themes_dir = "src/usecli/cli/themes"
9
+ theme = "default"
10
+ hide_init = false
11
+ hide_inspire = false
12
+ hide_make_command = false
13
+ hide_make_theme = false
@@ -17,4 +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"
20
+ USECLI_CONFIG_TOML = "usecli.config.toml"
@@ -1,8 +1,6 @@
1
1
  """Configuration manager for useCli CLI.
2
2
 
3
3
  Handles loading and accessing configuration from project-level files.
4
- Configuration is loaded from (in priority order):
5
- 1. pyproject.toml [tool.usecli] section (preferred for Python projects)
6
4
  """
7
5
 
8
6
  from __future__ import annotations
@@ -12,7 +10,7 @@ from pathlib import Path
12
10
  from typing import Any
13
11
 
14
12
  from usecli.cli.core.exceptions.config import UsecliConfigError
15
- from usecli.shared.config.globals import PYPROJECT_TOML, USECLI_TOML
13
+ from usecli.shared.config.globals import PYPROJECT_TOML, USECLI_CONFIG_TOML
16
14
 
17
15
  if sys.version_info >= (3, 11):
18
16
  import tomllib
@@ -58,16 +56,9 @@ def _dedupe_items(items: list[str]) -> list[str]:
58
56
 
59
57
 
60
58
  class ConfigManager:
61
- """Manages useCli configuration from project-level files.
59
+ """Manages useCli configuration from project-level files."""
62
60
 
63
- Configuration is loaded from:
64
- 1. pyproject.toml [tool.usecli] in current directory (highest priority)
65
- 2. Default values (lowest priority)
66
-
67
- Attributes:
68
- pyproject_path: Path to pyproject.toml in current directory.
69
- _config: The merged configuration dictionary.
70
- """
61
+ _SKIP_DIRS = {".venv", "venv"}
71
62
 
72
63
  DEFAULT_CONFIG: dict[str, Any] = {
73
64
  "title": "usecli",
@@ -88,7 +79,7 @@ class ConfigManager:
88
79
  def __init__(
89
80
  self,
90
81
  pyproject_path: Path | None = None,
91
- usecli_toml_path: Path | None = None,
82
+ usecli_config_path: Path | None = None,
92
83
  start_dir: Path | None = None,
93
84
  ) -> None:
94
85
  """Initialize the configuration manager.
@@ -107,17 +98,17 @@ class ConfigManager:
107
98
  start_dir / PYPROJECT_TOML
108
99
  )
109
100
 
110
- if usecli_toml_path is None:
111
- usecli_toml_path = self._find_usecli_toml(start_dir) or (
112
- start_dir / USECLI_TOML
101
+ if usecli_config_path is None:
102
+ usecli_config_path = self._find_usecli_config(start_dir) or (
103
+ start_dir / USECLI_CONFIG_TOML
113
104
  )
114
105
 
115
106
  self.pyproject_path: Path = pyproject_path
116
- self.usecli_toml_path: Path = usecli_toml_path
107
+ self.usecli_config_path: Path = usecli_config_path
117
108
  self.start_dir: Path = start_dir
118
109
  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
110
+ if detected_root is None and self.usecli_config_path.exists():
111
+ detected_root = self.usecli_config_path.parent
121
112
  self.project_root: Path = (detected_root or start_dir).resolve()
122
113
  self._config: dict[str, Any] = {}
123
114
  self._overrides: dict[str, Any] = {}
@@ -128,30 +119,16 @@ class ConfigManager:
128
119
  self._config = self.DEFAULT_CONFIG.copy()
129
120
  self._overrides = {}
130
121
 
131
- loaded = False
132
- if self.pyproject_path.exists():
133
- try:
134
- pyproject_config = self._load_pyproject_toml(self.pyproject_path)
135
- if pyproject_config:
136
- self._config = _deep_merge(self._config, pyproject_config)
137
- self._overrides = _deep_merge(self._overrides, pyproject_config)
138
- loaded = True
139
- except (tomllib.TOMLDecodeError, OSError) as e:
140
- raise UsecliConfigError(
141
- f"Failed to load pyproject.toml: {e}",
142
- config_file=str(self.pyproject_path),
143
- ) from e
144
-
145
- if not loaded and self.usecli_toml_path.exists():
122
+ if self.usecli_config_path.exists():
146
123
  try:
147
- usecli_config = self._load_usecli_toml(self.usecli_toml_path)
124
+ usecli_config = self._load_usecli_toml(self.usecli_config_path)
148
125
  if usecli_config:
149
126
  self._config = _deep_merge(self._config, usecli_config)
150
127
  self._overrides = _deep_merge(self._overrides, usecli_config)
151
128
  except (tomllib.TOMLDecodeError, OSError) as e:
152
129
  raise UsecliConfigError(
153
- f"Failed to load usecli.toml: {e}",
154
- config_file=str(self.usecli_toml_path),
130
+ f"Failed to load {USECLI_CONFIG_TOML}: {e}",
131
+ config_file=str(self.usecli_config_path),
155
132
  ) from e
156
133
 
157
134
  default_themes = _normalize_themes_dir(self.DEFAULT_CONFIG.get("themes_dir"))
@@ -188,11 +165,11 @@ class ConfigManager:
188
165
  return None
189
166
 
190
167
  @classmethod
191
- def _find_usecli_toml(cls, start_dir: Path) -> Path | None:
168
+ def _find_usecli_config(cls, start_dir: Path) -> Path | None:
192
169
  current = start_dir.resolve()
193
170
 
194
171
  while True:
195
- config_path = current / USECLI_TOML
172
+ config_path = current / USECLI_CONFIG_TOML
196
173
  if config_path.exists():
197
174
  return config_path
198
175
 
@@ -201,46 +178,72 @@ class ConfigManager:
201
178
  break
202
179
  current = parent
203
180
 
204
- return cls._find_usecli_toml_on_sys_path()
181
+ search_root = find_project_root(start_dir) or start_dir.resolve()
182
+ recursive_match = cls._find_usecli_config_in_tree(search_root, start_dir)
183
+ if recursive_match:
184
+ return recursive_match
185
+
186
+ return cls._find_usecli_config_on_sys_path()
205
187
 
206
188
  @staticmethod
207
- def _find_usecli_toml_on_sys_path() -> Path | None:
189
+ def _find_usecli_config_in_tree(root_dir: Path, start_dir: Path) -> Path | None:
190
+ if not root_dir.exists() or not root_dir.is_dir():
191
+ return None
192
+
193
+ candidates = [
194
+ path
195
+ for path in root_dir.rglob(USECLI_CONFIG_TOML)
196
+ if not any(part in ConfigManager._SKIP_DIRS for part in path.parts)
197
+ ]
198
+ if not candidates:
199
+ return None
200
+
201
+ start_dir = start_dir.resolve()
202
+ preferred: list[Path] = []
203
+ for path in candidates:
204
+ try:
205
+ path.relative_to(start_dir)
206
+ preferred.append(path)
207
+ except ValueError:
208
+ continue
209
+
210
+ selection = preferred or candidates
211
+
212
+ def _depth_key(path: Path) -> tuple[int, str]:
213
+ try:
214
+ relative = path.relative_to(start_dir)
215
+ return (len(relative.parts), str(path))
216
+ except ValueError:
217
+ relative = path.relative_to(root_dir)
218
+ return (len(relative.parts), str(path))
219
+
220
+ selection.sort(key=_depth_key)
221
+ return selection[0]
222
+
223
+ @staticmethod
224
+ def _find_usecli_config_on_sys_path() -> Path | None:
208
225
  for entry in sys.path:
209
226
  if not entry:
210
227
  continue
211
228
  path = Path(entry)
212
229
  if not path.exists() or not path.is_dir():
213
230
  continue
214
- candidate = path / USECLI_TOML
231
+ candidate = path / USECLI_CONFIG_TOML
215
232
  if candidate.exists():
216
233
  return candidate
217
- for child in path.glob(f"*/{USECLI_TOML}"):
234
+ for child in path.glob(f"*/{USECLI_CONFIG_TOML}"):
218
235
  if child.exists():
219
236
  return child
220
237
  return None
221
238
 
222
- @staticmethod
223
- def _load_pyproject_toml(path: Path) -> dict[str, Any]:
224
- """Load pyproject.toml and return [tool.usecli] section.
225
-
226
- Args:
227
- path: Path to the pyproject.toml file.
228
-
229
- Returns:
230
- Parsed [tool.usecli] content as a dictionary, or empty dict.
231
- """
232
- with open(path, "rb") as f:
233
- data = tomllib.load(f)
234
- return data.get("tool", {}).get("usecli", {})
235
-
236
239
  @staticmethod
237
240
  def _load_usecli_toml(path: Path) -> dict[str, Any]:
238
241
  with open(path, "rb") as f:
239
242
  data = tomllib.load(f)
240
243
 
241
244
  tool_section = data.get("tool", {})
242
- if isinstance(tool_section, dict):
243
- usecli_section = tool_section.get("usecli", {})
245
+ if isinstance(tool_section, dict) and "usecli" in tool_section:
246
+ usecli_section = tool_section.get("usecli")
244
247
  if isinstance(usecli_section, dict):
245
248
  return usecli_section
246
249
 
@@ -329,14 +332,7 @@ class ConfigManager:
329
332
 
330
333
  @property
331
334
  def pyproject_exists(self) -> bool:
332
- """Check if pyproject.toml with [tool.usecli] exists."""
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
335
+ return self.usecli_config_path.exists()
340
336
 
341
337
  @staticmethod
342
338
  def _load_project_version(path: Path) -> str | None:
@@ -382,22 +378,29 @@ def find_project_root(start_dir: Path | None = None) -> Path | None:
382
378
 
383
379
  current = start_dir.resolve()
384
380
 
381
+ git_root: Path | None = None
385
382
  while True:
386
383
  pyproject_path = current / PYPROJECT_TOML
387
384
  if pyproject_path.exists():
388
385
  return current
389
386
 
390
- usecli_path = current / USECLI_TOML
387
+ usecli_path = current / USECLI_CONFIG_TOML
391
388
  if usecli_path.exists():
392
389
  return current
393
390
 
394
391
  git_dir = current / ".git"
395
392
  if git_dir.exists():
396
- return current
393
+ git_root = current
394
+ break
397
395
 
398
396
  parent = current.parent
399
397
  if parent == current:
400
398
  break
401
399
  current = parent
402
400
 
403
- return None
401
+ search_root = git_root or start_dir.resolve()
402
+ config_match = ConfigManager._find_usecli_config_in_tree(search_root, start_dir)
403
+ if config_match:
404
+ return config_match.parent
405
+
406
+ return git_root
File without changes
File without changes
File without changes
File without changes
File without changes