usecli 0.1.33__tar.gz → 0.1.34__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.34}/PKG-INFO +4 -2
  2. {usecli-0.1.33 → usecli-0.1.34}/README.md +3 -1
  3. {usecli-0.1.33 → usecli-0.1.34}/pyproject.toml +4 -9
  4. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/init_command.py +29 -75
  5. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/config/colors.py +55 -13
  6. usecli-0.1.33/src/usecli/cli/templates/usecli.toml.j2 → usecli-0.1.34/src/usecli/cli/templates/usecli.config.toml.j2 +1 -1
  7. usecli-0.1.34/src/usecli/cli/usecli.config.toml +13 -0
  8. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/shared/config/globals.py +1 -1
  9. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/shared/config/manager.py +68 -71
  10. {usecli-0.1.33 → usecli-0.1.34}/LICENSE +0 -0
  11. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/__init__.py +0 -0
  12. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/__init__.py +0 -0
  13. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/README.md +0 -0
  14. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/__init__.py +0 -0
  15. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/custom/README.md +0 -0
  16. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/custom/__init__.py +0 -0
  17. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/__init__.py +0 -0
  18. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
  19. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
  20. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
  21. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
  22. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
  23. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
  24. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
  25. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
  26. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
  27. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
  28. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
  29. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/config/__init__.py +0 -0
  30. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/__init__.py +0 -0
  31. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/base_command.py +0 -0
  32. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/error/__init__.py +0 -0
  33. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/error/handler.py +0 -0
  34. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/error/utils.py +0 -0
  35. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/exceptions/__init__.py +0 -0
  36. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/exceptions/base.py +0 -0
  37. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/exceptions/config.py +0 -0
  38. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/exceptions/usage.py +0 -0
  39. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/exceptions/validation.py +0 -0
  40. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/skill_generator.py +0 -0
  41. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/ui/__init__.py +0 -0
  42. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/ui/list.py +0 -0
  43. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/ui/title.py +0 -0
  44. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/validators/__init__.py +0 -0
  45. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/validators/network.py +0 -0
  46. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/validators/numeric.py +0 -0
  47. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/validators/path.py +0 -0
  48. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/core/validators/string.py +0 -0
  49. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/services/__init__.py +0 -0
  50. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/services/command_service.py +0 -0
  51. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/templates/command.py.j2 +0 -0
  52. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/templates/theme.toml.j2 +0 -0
  53. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/ayu_dark.toml +0 -0
  54. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
  55. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
  56. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
  57. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
  58. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/default.toml +0 -0
  59. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/dracula.toml +0 -0
  60. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
  61. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/nord.toml +0 -0
  62. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/themes/tokyo_night.toml +0 -0
  63. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/utils/__init__.py +0 -0
  64. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/utils/interactive/__init__.py +0 -0
  65. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
  66. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/menu.py +0 -0
  67. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/params.py +0 -0
  68. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/shared/__init__.py +0 -0
  69. {usecli-0.1.33 → usecli-0.1.34}/src/usecli/shared/config/__init__.py +0 -0
  70. {usecli-0.1.33 → usecli-0.1.34}/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.34
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.34"
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,29 @@ 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
+ project_root: Path,
92
+ config_content: str,
93
+ force: bool,
94
+ commands_path: Path,
118
95
  ) -> str:
119
- config_path = project_root / USECLI_TOML
96
+ config_root = project_root
97
+ if commands_path.parent != project_root:
98
+ config_root = commands_path.parent
99
+ config_path = config_root / USECLI_CONFIG_TOML
100
+ discovered_config = ConfigManager(start_dir=config_root).usecli_config_path
101
+ if discovered_config.exists():
102
+ config_path = discovered_config
120
103
  existed = config_path.exists()
121
104
  if existed and not force:
122
105
  should_overwrite = Confirm.ask(
123
- f"[{COLOR.WARNING}]usecli.toml already exists at {config_path}.[/{COLOR.WARNING}]\n"
106
+ f"[{COLOR.WARNING}]usecli.config.toml already exists at {config_path}.[/{COLOR.WARNING}]\n"
124
107
  "Overwrite it with the new settings from this init run?",
125
108
  default=False,
126
109
  )
@@ -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,20 @@ 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
+ usecli_config_status = self._write_usecli_config(
720
+ project_root, config_content, force, commands_path
767
721
  )
768
- if usecli_toml_status == "created":
722
+ if usecli_config_status == "created":
769
723
  console.print(
770
- f"[{COLOR.SUCCESS}]Created {USECLI_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
724
+ f"[{COLOR.SUCCESS}]Created {USECLI_CONFIG_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
771
725
  )
772
- elif usecli_toml_status == "updated":
726
+ elif usecli_config_status == "updated":
773
727
  console.print(
774
- f"[{COLOR.SUCCESS}]Updated {USECLI_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
728
+ f"[{COLOR.SUCCESS}]Updated {USECLI_CONFIG_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
775
729
  )
776
- elif usecli_toml_status == "skipped":
730
+ elif usecli_config_status == "skipped":
777
731
  console.print(
778
- f"[{COLOR.WARNING}]Skipped updating {USECLI_TOML}.[/{COLOR.WARNING}]"
732
+ f"[{COLOR.WARNING}]Skipped updating {USECLI_CONFIG_TOML}.[/{COLOR.WARNING}]"
779
733
  )
780
734
 
781
735
  # 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,97 @@ 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 = [path for path in root_dir.rglob(USECLI_CONFIG_TOML)]
56
+ if not candidates:
57
+ return None
58
+
59
+ start_dir = start_dir.resolve()
60
+ preferred: list[Path] = []
61
+ for path in candidates:
62
+ try:
63
+ path.relative_to(start_dir)
64
+ preferred.append(path)
65
+ except ValueError:
66
+ continue
67
+
68
+ selection = preferred or candidates
69
+
70
+ def _depth_key(path: Path) -> tuple[int, str]:
71
+ try:
72
+ relative = path.relative_to(start_dir)
73
+ return (len(relative.parts), str(path))
74
+ except ValueError:
75
+ relative = path.relative_to(root_dir)
76
+ return (len(relative.parts), str(path))
77
+
78
+ selection.sort(key=_depth_key)
79
+ return selection[0]
80
+
81
+
51
82
  def _find_project_root(start_dir: Path | None = None) -> Path | None:
52
83
  if start_dir is None:
53
84
  start_dir = Path.cwd()
54
85
 
55
86
  current = start_dir.resolve()
87
+ git_root: Path | None = None
56
88
 
57
89
  while True:
58
90
  pyproject_path = current / PYPROJECT_TOML
59
91
  if pyproject_path.exists():
60
92
  return current
61
93
 
62
- usecli_path = current / USECLI_TOML
94
+ usecli_path = current / USECLI_CONFIG_TOML
63
95
  if usecli_path.exists():
64
96
  return current
65
97
 
66
98
  git_dir = current / ".git"
67
99
  if git_dir.exists():
68
- return current
100
+ git_root = current
101
+ break
69
102
 
70
103
  parent = current.parent
71
104
  if parent == current:
72
105
  break
73
106
  current = parent
74
107
 
75
- return None
108
+ search_root = git_root or start_dir.resolve()
109
+ config_match = _find_usecli_config_path(search_root, start_dir)
110
+ if config_match:
111
+ return config_match.parent
112
+
113
+ return git_root
76
114
 
77
115
 
78
116
  def _load_usecli_config(project_root: Path | None) -> dict[str, Any]:
79
117
  if project_root is None:
80
118
  return {}
81
119
 
82
- pyproject_path = project_root / PYPROJECT_TOML
83
- if not pyproject_path.exists():
120
+ config_path = project_root / USECLI_CONFIG_TOML
121
+ if not config_path.exists():
122
+ config_path = _find_usecli_config_path(project_root, project_root)
123
+ if not config_path or not config_path.exists():
84
124
  return {}
85
125
 
86
126
  try:
87
- data = tomllib.loads(pyproject_path.read_text())
127
+ data = tomllib.loads(config_path.read_text())
88
128
  except (tomllib.TOMLDecodeError, OSError):
89
129
  return {}
90
130
 
91
131
  tool = data.get("tool", {})
92
- if not isinstance(tool, dict):
93
- return {}
132
+ if isinstance(tool, dict) and "usecli" in tool:
133
+ usecli_config = tool.get("usecli")
134
+ if isinstance(usecli_config, dict):
135
+ return usecli_config
94
136
 
95
- usecli_config = tool.get("usecli", {})
96
- if not isinstance(usecli_config, dict):
97
- return {}
137
+ usecli_section = data.get("usecli", {})
138
+ if isinstance(usecli_section, dict):
139
+ return usecli_section
98
140
 
99
- return usecli_config
141
+ return {}
100
142
 
101
143
 
102
144
  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,7 @@ def _dedupe_items(items: list[str]) -> list[str]:
58
56
 
59
57
 
60
58
  class ConfigManager:
61
- """Manages useCli configuration from project-level files.
62
-
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
- """
59
+ """Manages useCli configuration from project-level files."""
71
60
 
72
61
  DEFAULT_CONFIG: dict[str, Any] = {
73
62
  "title": "usecli",
@@ -88,7 +77,7 @@ class ConfigManager:
88
77
  def __init__(
89
78
  self,
90
79
  pyproject_path: Path | None = None,
91
- usecli_toml_path: Path | None = None,
80
+ usecli_config_path: Path | None = None,
92
81
  start_dir: Path | None = None,
93
82
  ) -> None:
94
83
  """Initialize the configuration manager.
@@ -107,17 +96,17 @@ class ConfigManager:
107
96
  start_dir / PYPROJECT_TOML
108
97
  )
109
98
 
110
- if usecli_toml_path is None:
111
- usecli_toml_path = self._find_usecli_toml(start_dir) or (
112
- start_dir / USECLI_TOML
99
+ if usecli_config_path is None:
100
+ usecli_config_path = self._find_usecli_config(start_dir) or (
101
+ start_dir / USECLI_CONFIG_TOML
113
102
  )
114
103
 
115
104
  self.pyproject_path: Path = pyproject_path
116
- self.usecli_toml_path: Path = usecli_toml_path
105
+ self.usecli_config_path: Path = usecli_config_path
117
106
  self.start_dir: Path = start_dir
118
107
  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
108
+ if detected_root is None and self.usecli_config_path.exists():
109
+ detected_root = self.usecli_config_path.parent
121
110
  self.project_root: Path = (detected_root or start_dir).resolve()
122
111
  self._config: dict[str, Any] = {}
123
112
  self._overrides: dict[str, Any] = {}
@@ -128,30 +117,16 @@ class ConfigManager:
128
117
  self._config = self.DEFAULT_CONFIG.copy()
129
118
  self._overrides = {}
130
119
 
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():
120
+ if self.usecli_config_path.exists():
146
121
  try:
147
- usecli_config = self._load_usecli_toml(self.usecli_toml_path)
122
+ usecli_config = self._load_usecli_toml(self.usecli_config_path)
148
123
  if usecli_config:
149
124
  self._config = _deep_merge(self._config, usecli_config)
150
125
  self._overrides = _deep_merge(self._overrides, usecli_config)
151
126
  except (tomllib.TOMLDecodeError, OSError) as e:
152
127
  raise UsecliConfigError(
153
- f"Failed to load usecli.toml: {e}",
154
- config_file=str(self.usecli_toml_path),
128
+ f"Failed to load {USECLI_CONFIG_TOML}: {e}",
129
+ config_file=str(self.usecli_config_path),
155
130
  ) from e
156
131
 
157
132
  default_themes = _normalize_themes_dir(self.DEFAULT_CONFIG.get("themes_dir"))
@@ -188,11 +163,11 @@ class ConfigManager:
188
163
  return None
189
164
 
190
165
  @classmethod
191
- def _find_usecli_toml(cls, start_dir: Path) -> Path | None:
166
+ def _find_usecli_config(cls, start_dir: Path) -> Path | None:
192
167
  current = start_dir.resolve()
193
168
 
194
169
  while True:
195
- config_path = current / USECLI_TOML
170
+ config_path = current / USECLI_CONFIG_TOML
196
171
  if config_path.exists():
197
172
  return config_path
198
173
 
@@ -201,46 +176,68 @@ class ConfigManager:
201
176
  break
202
177
  current = parent
203
178
 
204
- return cls._find_usecli_toml_on_sys_path()
179
+ search_root = find_project_root(start_dir) or start_dir.resolve()
180
+ recursive_match = cls._find_usecli_config_in_tree(search_root, start_dir)
181
+ if recursive_match:
182
+ return recursive_match
183
+
184
+ return cls._find_usecli_config_on_sys_path()
185
+
186
+ @staticmethod
187
+ def _find_usecli_config_in_tree(root_dir: Path, start_dir: Path) -> Path | None:
188
+ if not root_dir.exists() or not root_dir.is_dir():
189
+ return None
190
+
191
+ candidates = [path for path in root_dir.rglob(USECLI_CONFIG_TOML)]
192
+ if not candidates:
193
+ return None
194
+
195
+ start_dir = start_dir.resolve()
196
+ preferred: list[Path] = []
197
+ for path in candidates:
198
+ try:
199
+ path.relative_to(start_dir)
200
+ preferred.append(path)
201
+ except ValueError:
202
+ continue
203
+
204
+ selection = preferred or candidates
205
+
206
+ def _depth_key(path: Path) -> tuple[int, str]:
207
+ try:
208
+ relative = path.relative_to(start_dir)
209
+ return (len(relative.parts), str(path))
210
+ except ValueError:
211
+ relative = path.relative_to(root_dir)
212
+ return (len(relative.parts), str(path))
213
+
214
+ selection.sort(key=_depth_key)
215
+ return selection[0]
205
216
 
206
217
  @staticmethod
207
- def _find_usecli_toml_on_sys_path() -> Path | None:
218
+ def _find_usecli_config_on_sys_path() -> Path | None:
208
219
  for entry in sys.path:
209
220
  if not entry:
210
221
  continue
211
222
  path = Path(entry)
212
223
  if not path.exists() or not path.is_dir():
213
224
  continue
214
- candidate = path / USECLI_TOML
225
+ candidate = path / USECLI_CONFIG_TOML
215
226
  if candidate.exists():
216
227
  return candidate
217
- for child in path.glob(f"*/{USECLI_TOML}"):
228
+ for child in path.glob(f"*/{USECLI_CONFIG_TOML}"):
218
229
  if child.exists():
219
230
  return child
220
231
  return None
221
232
 
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
233
  @staticmethod
237
234
  def _load_usecli_toml(path: Path) -> dict[str, Any]:
238
235
  with open(path, "rb") as f:
239
236
  data = tomllib.load(f)
240
237
 
241
238
  tool_section = data.get("tool", {})
242
- if isinstance(tool_section, dict):
243
- usecli_section = tool_section.get("usecli", {})
239
+ if isinstance(tool_section, dict) and "usecli" in tool_section:
240
+ usecli_section = tool_section.get("usecli")
244
241
  if isinstance(usecli_section, dict):
245
242
  return usecli_section
246
243
 
@@ -329,14 +326,7 @@ class ConfigManager:
329
326
 
330
327
  @property
331
328
  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
329
+ return self.usecli_config_path.exists()
340
330
 
341
331
  @staticmethod
342
332
  def _load_project_version(path: Path) -> str | None:
@@ -382,22 +372,29 @@ def find_project_root(start_dir: Path | None = None) -> Path | None:
382
372
 
383
373
  current = start_dir.resolve()
384
374
 
375
+ git_root: Path | None = None
385
376
  while True:
386
377
  pyproject_path = current / PYPROJECT_TOML
387
378
  if pyproject_path.exists():
388
379
  return current
389
380
 
390
- usecli_path = current / USECLI_TOML
381
+ usecli_path = current / USECLI_CONFIG_TOML
391
382
  if usecli_path.exists():
392
383
  return current
393
384
 
394
385
  git_dir = current / ".git"
395
386
  if git_dir.exists():
396
- return current
387
+ git_root = current
388
+ break
397
389
 
398
390
  parent = current.parent
399
391
  if parent == current:
400
392
  break
401
393
  current = parent
402
394
 
403
- return None
395
+ search_root = git_root or start_dir.resolve()
396
+ config_match = ConfigManager._find_usecli_config_in_tree(search_root, start_dir)
397
+ if config_match:
398
+ return config_match.parent
399
+
400
+ return git_root
File without changes
File without changes
File without changes
File without changes
File without changes