usecli 0.1.32__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.32 → usecli-0.1.34}/PKG-INFO +4 -2
  2. {usecli-0.1.32 → usecli-0.1.34}/README.md +3 -1
  3. {usecli-0.1.32 → usecli-0.1.34}/pyproject.toml +4 -9
  4. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/init_command.py +50 -62
  5. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/config/colors.py +60 -12
  6. usecli-0.1.32/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.32 → usecli-0.1.34}/src/usecli/shared/config/globals.py +1 -0
  9. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/shared/config/manager.py +116 -37
  10. {usecli-0.1.32 → usecli-0.1.34}/LICENSE +0 -0
  11. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/__init__.py +0 -0
  12. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/__init__.py +0 -0
  13. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/README.md +0 -0
  14. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/__init__.py +0 -0
  15. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/custom/README.md +0 -0
  16. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/custom/__init__.py +0 -0
  17. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/__init__.py +0 -0
  18. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
  19. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
  20. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
  21. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
  22. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
  23. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
  24. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
  25. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
  26. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
  27. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
  28. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
  29. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/config/__init__.py +0 -0
  30. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/__init__.py +0 -0
  31. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/base_command.py +0 -0
  32. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/error/__init__.py +0 -0
  33. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/error/handler.py +0 -0
  34. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/error/utils.py +0 -0
  35. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/exceptions/__init__.py +0 -0
  36. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/exceptions/base.py +0 -0
  37. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/exceptions/config.py +0 -0
  38. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/exceptions/usage.py +0 -0
  39. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/exceptions/validation.py +0 -0
  40. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/skill_generator.py +0 -0
  41. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/ui/__init__.py +0 -0
  42. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/ui/list.py +0 -0
  43. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/ui/title.py +0 -0
  44. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/validators/__init__.py +0 -0
  45. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/validators/network.py +0 -0
  46. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/validators/numeric.py +0 -0
  47. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/validators/path.py +0 -0
  48. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/core/validators/string.py +0 -0
  49. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/services/__init__.py +0 -0
  50. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/services/command_service.py +0 -0
  51. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/templates/command.py.j2 +0 -0
  52. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/templates/theme.toml.j2 +0 -0
  53. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/ayu_dark.toml +0 -0
  54. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
  55. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
  56. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
  57. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
  58. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/default.toml +0 -0
  59. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/dracula.toml +0 -0
  60. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
  61. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/nord.toml +0 -0
  62. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/themes/tokyo_night.toml +0 -0
  63. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/utils/__init__.py +0 -0
  64. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/utils/interactive/__init__.py +0 -0
  65. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
  66. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/menu.py +0 -0
  67. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/params.py +0 -0
  68. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/shared/__init__.py +0 -0
  69. {usecli-0.1.32 → usecli-0.1.34}/src/usecli/shared/config/__init__.py +0 -0
  70. {usecli-0.1.32 → 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.32
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.32"
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
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,17 +81,38 @@ 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
 
89
+ def _write_usecli_config(
90
+ self,
91
+ project_root: Path,
92
+ config_content: str,
93
+ force: bool,
94
+ commands_path: Path,
95
+ ) -> str:
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
103
+ existed = config_path.exists()
104
+ if existed and not force:
105
+ should_overwrite = Confirm.ask(
106
+ f"[{COLOR.WARNING}]usecli.config.toml already exists at {config_path}.[/{COLOR.WARNING}]\n"
107
+ "Overwrite it with the new settings from this init run?",
108
+ default=False,
109
+ )
110
+ if not should_overwrite:
111
+ return "skipped"
112
+
113
+ config_path.write_text(config_content.rstrip() + "\n")
114
+ return "updated" if existed else "created"
115
+
116
116
  def _ensure_project_scripts(
117
117
  self, pyproject_path: Path, command_name: str, force: bool
118
118
  ) -> str:
@@ -449,7 +449,6 @@ class InitCommand(BaseCommand):
449
449
  title: str,
450
450
  description: str,
451
451
  commands_dir: str,
452
- config_content: str,
453
452
  ) -> None:
454
453
  parts = Path(commands_dir).parts
455
454
  root_package = parts[0] if parts else "src"
@@ -476,8 +475,7 @@ build-backend = "setuptools.build_meta"
476
475
  [tool.setuptools.packages.find]
477
476
  where = ["."]
478
477
  include = ["{root_package}*"]
479
-
480
- {config_content}'''
478
+ '''
481
479
 
482
480
  pyproject_path.write_text(pyproject_content)
483
481
 
@@ -595,21 +593,6 @@ include = ["{root_package}*"]
595
593
  console.print()
596
594
  console.print(f"[{COLOR.PRIMARY}]{title_text}")
597
595
 
598
- # Check if config already exists
599
- existing_source = self._get_config_source(pyproject_path)
600
-
601
- if existing_source and not force:
602
- should_overwrite = Confirm.ask(
603
- f"[{COLOR.WARNING}]usecli config already exists in {existing_source}.[/{COLOR.WARNING}]\n"
604
- "Overwrite it with the new settings from this init run?",
605
- default=False,
606
- )
607
- if not should_overwrite:
608
- console.print(
609
- f"[{COLOR.WARNING}]Skipping config update.[/{COLOR.WARNING}]"
610
- )
611
- return
612
-
613
596
  # Create the commands directory
614
597
  if not commands_path.exists():
615
598
  commands_path.mkdir(parents=True, exist_ok=True)
@@ -669,7 +652,9 @@ include = ["{root_package}*"]
669
652
  )
670
653
 
671
654
  # Load the template
672
- template_path = Path(__file__).parent.parent / "templates" / "usecli.toml.j2"
655
+ template_path = (
656
+ Path(__file__).parent.parent / "templates" / "usecli.config.toml.j2"
657
+ )
673
658
  template_content = template_path.read_text()
674
659
  template = Template(template_content)
675
660
 
@@ -685,22 +670,10 @@ include = ["{root_package}*"]
685
670
  )
686
671
 
687
672
  scripts_status: str | None = None
673
+ usecli_config_status: str | None = None
688
674
 
689
675
  # Check if pyproject.toml exists
690
676
  if pyproject_path.exists():
691
- content = pyproject_path.read_text()
692
- if "[tool.usecli]" in content:
693
- self._replace_config_in_pyproject(pyproject_path, config_content)
694
- console.print(
695
- f"[{COLOR.SUCCESS}]Updated [tool.usecli] in {pyproject_path}[/{COLOR.SUCCESS}]"
696
- )
697
- else:
698
- with open(pyproject_path, "a") as f:
699
- f.write("\n\n" + config_content)
700
- console.print(
701
- f"[{COLOR.SUCCESS}]Added [tool.usecli] to {pyproject_path}[/{COLOR.SUCCESS}]"
702
- )
703
-
704
677
  scripts_status = self._ensure_project_scripts(
705
678
  pyproject_path, command_name, force
706
679
  )
@@ -735,7 +708,6 @@ include = ["{root_package}*"]
735
708
  title,
736
709
  description,
737
710
  commands_dir,
738
- config_content,
739
711
  )
740
712
  console.print(
741
713
  f"[{COLOR.SUCCESS}]Created {pyproject_path}[/{COLOR.SUCCESS}]"
@@ -744,6 +716,22 @@ include = ["{root_package}*"]
744
716
 
745
717
  self._sync_environment(project_root, command_name)
746
718
 
719
+ usecli_config_status = self._write_usecli_config(
720
+ project_root, config_content, force, commands_path
721
+ )
722
+ if usecli_config_status == "created":
723
+ console.print(
724
+ f"[{COLOR.SUCCESS}]Created {USECLI_CONFIG_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
725
+ )
726
+ elif usecli_config_status == "updated":
727
+ console.print(
728
+ f"[{COLOR.SUCCESS}]Updated {USECLI_CONFIG_TOML} for runtime config fallback[/{COLOR.SUCCESS}]"
729
+ )
730
+ elif usecli_config_status == "skipped":
731
+ console.print(
732
+ f"[{COLOR.WARNING}]Skipped updating {USECLI_CONFIG_TOML}.[/{COLOR.WARNING}]"
733
+ )
734
+
747
735
  # Show summary
748
736
  summary_command = (
749
737
  command_name
@@ -21,6 +21,8 @@ else:
21
21
  import tomli as tomllib
22
22
 
23
23
 
24
+ PYPROJECT_TOML = "pyproject.toml"
25
+ USECLI_CONFIG_TOML = "usecli.config.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] = {
@@ -46,51 +48,97 @@ DEFAULT_THEME_COLORS: dict[str, str] = {
46
48
  }
47
49
 
48
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
+
49
82
  def _find_project_root(start_dir: Path | None = None) -> Path | None:
50
83
  if start_dir is None:
51
84
  start_dir = Path.cwd()
52
85
 
53
86
  current = start_dir.resolve()
87
+ git_root: Path | None = None
54
88
 
55
89
  while True:
56
- pyproject_path = current / "pyproject.toml"
90
+ pyproject_path = current / PYPROJECT_TOML
57
91
  if pyproject_path.exists():
58
92
  return current
59
93
 
94
+ usecli_path = current / USECLI_CONFIG_TOML
95
+ if usecli_path.exists():
96
+ return current
97
+
60
98
  git_dir = current / ".git"
61
99
  if git_dir.exists():
62
- return current
100
+ git_root = current
101
+ break
63
102
 
64
103
  parent = current.parent
65
104
  if parent == current:
66
105
  break
67
106
  current = parent
68
107
 
69
- 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
70
114
 
71
115
 
72
116
  def _load_usecli_config(project_root: Path | None) -> dict[str, Any]:
73
117
  if project_root is None:
74
118
  return {}
75
119
 
76
- pyproject_path = project_root / "pyproject.toml"
77
- 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():
78
124
  return {}
79
125
 
80
126
  try:
81
- data = tomllib.loads(pyproject_path.read_text())
127
+ data = tomllib.loads(config_path.read_text())
82
128
  except (tomllib.TOMLDecodeError, OSError):
83
129
  return {}
84
130
 
85
131
  tool = data.get("tool", {})
86
- if not isinstance(tool, dict):
87
- 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
88
136
 
89
- usecli_config = tool.get("usecli", {})
90
- if not isinstance(usecli_config, dict):
91
- return {}
137
+ usecli_section = data.get("usecli", {})
138
+ if isinstance(usecli_section, dict):
139
+ return usecli_section
92
140
 
93
- return usecli_config
141
+ return {}
94
142
 
95
143
 
96
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,3 +17,4 @@ THEMES_DIR = CLI_ROOT / "themes"
17
17
 
18
18
  # Config file names
19
19
  PYPROJECT_TOML = "pyproject.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,6 +10,7 @@ from pathlib import Path
12
10
  from typing import Any
13
11
 
14
12
  from usecli.cli.core.exceptions.config import UsecliConfigError
13
+ from usecli.shared.config.globals import PYPROJECT_TOML, USECLI_CONFIG_TOML
15
14
 
16
15
  if sys.version_info >= (3, 11):
17
16
  import tomllib
@@ -57,16 +56,7 @@ def _dedupe_items(items: list[str]) -> list[str]:
57
56
 
58
57
 
59
58
  class ConfigManager:
60
- """Manages useCli configuration from project-level files.
61
-
62
- Configuration is loaded from:
63
- 1. pyproject.toml [tool.usecli] in current directory (highest priority)
64
- 2. Default values (lowest priority)
65
-
66
- Attributes:
67
- pyproject_path: Path to pyproject.toml in current directory.
68
- _config: The merged configuration dictionary.
69
- """
59
+ """Manages useCli configuration from project-level files."""
70
60
 
71
61
  DEFAULT_CONFIG: dict[str, Any] = {
72
62
  "title": "usecli",
@@ -87,6 +77,7 @@ class ConfigManager:
87
77
  def __init__(
88
78
  self,
89
79
  pyproject_path: Path | None = None,
80
+ usecli_config_path: Path | None = None,
90
81
  start_dir: Path | None = None,
91
82
  ) -> None:
92
83
  """Initialize the configuration manager.
@@ -102,12 +93,21 @@ class ConfigManager:
102
93
 
103
94
  if pyproject_path is None:
104
95
  pyproject_path = self._find_pyproject_toml(start_dir) or (
105
- start_dir / "pyproject.toml"
96
+ start_dir / PYPROJECT_TOML
97
+ )
98
+
99
+ if usecli_config_path is None:
100
+ usecli_config_path = self._find_usecli_config(start_dir) or (
101
+ start_dir / USECLI_CONFIG_TOML
106
102
  )
107
103
 
108
104
  self.pyproject_path: Path = pyproject_path
105
+ self.usecli_config_path: Path = usecli_config_path
109
106
  self.start_dir: Path = start_dir
110
- self.project_root: Path = find_project_root(start_dir) or start_dir.resolve()
107
+ detected_root = find_project_root(start_dir)
108
+ if detected_root is None and self.usecli_config_path.exists():
109
+ detected_root = self.usecli_config_path.parent
110
+ self.project_root: Path = (detected_root or start_dir).resolve()
111
111
  self._config: dict[str, Any] = {}
112
112
  self._overrides: dict[str, Any] = {}
113
113
  self._load_config()
@@ -117,16 +117,16 @@ class ConfigManager:
117
117
  self._config = self.DEFAULT_CONFIG.copy()
118
118
  self._overrides = {}
119
119
 
120
- if self.pyproject_path.exists():
120
+ if self.usecli_config_path.exists():
121
121
  try:
122
- pyproject_config = self._load_pyproject_toml(self.pyproject_path)
123
- if pyproject_config:
124
- self._config = _deep_merge(self._config, pyproject_config)
125
- self._overrides = _deep_merge(self._overrides, pyproject_config)
122
+ usecli_config = self._load_usecli_toml(self.usecli_config_path)
123
+ if usecli_config:
124
+ self._config = _deep_merge(self._config, usecli_config)
125
+ self._overrides = _deep_merge(self._overrides, usecli_config)
126
126
  except (tomllib.TOMLDecodeError, OSError) as e:
127
127
  raise UsecliConfigError(
128
- f"Failed to load pyproject.toml: {e}",
129
- config_file=str(self.pyproject_path),
128
+ f"Failed to load {USECLI_CONFIG_TOML}: {e}",
129
+ config_file=str(self.usecli_config_path),
130
130
  ) from e
131
131
 
132
132
  default_themes = _normalize_themes_dir(self.DEFAULT_CONFIG.get("themes_dir"))
@@ -151,7 +151,7 @@ class ConfigManager:
151
151
  current = start_dir.resolve()
152
152
 
153
153
  while True:
154
- pyproject_path = current / "pyproject.toml"
154
+ pyproject_path = current / PYPROJECT_TOML
155
155
  if pyproject_path.exists():
156
156
  return pyproject_path
157
157
 
@@ -162,19 +162,90 @@ class ConfigManager:
162
162
 
163
163
  return None
164
164
 
165
+ @classmethod
166
+ def _find_usecli_config(cls, start_dir: Path) -> Path | None:
167
+ current = start_dir.resolve()
168
+
169
+ while True:
170
+ config_path = current / USECLI_CONFIG_TOML
171
+ if config_path.exists():
172
+ return config_path
173
+
174
+ parent = current.parent
175
+ if parent == current:
176
+ break
177
+ current = parent
178
+
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
+
165
186
  @staticmethod
166
- def _load_pyproject_toml(path: Path) -> dict[str, Any]:
167
- """Load pyproject.toml and return [tool.usecli] section.
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
168
190
 
169
- Args:
170
- path: Path to the pyproject.toml file.
191
+ candidates = [path for path in root_dir.rglob(USECLI_CONFIG_TOML)]
192
+ if not candidates:
193
+ return None
171
194
 
172
- Returns:
173
- Parsed [tool.usecli] content as a dictionary, or empty dict.
174
- """
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]
216
+
217
+ @staticmethod
218
+ def _find_usecli_config_on_sys_path() -> Path | None:
219
+ for entry in sys.path:
220
+ if not entry:
221
+ continue
222
+ path = Path(entry)
223
+ if not path.exists() or not path.is_dir():
224
+ continue
225
+ candidate = path / USECLI_CONFIG_TOML
226
+ if candidate.exists():
227
+ return candidate
228
+ for child in path.glob(f"*/{USECLI_CONFIG_TOML}"):
229
+ if child.exists():
230
+ return child
231
+ return None
232
+
233
+ @staticmethod
234
+ def _load_usecli_toml(path: Path) -> dict[str, Any]:
175
235
  with open(path, "rb") as f:
176
236
  data = tomllib.load(f)
177
- return data.get("tool", {}).get("usecli", {})
237
+
238
+ tool_section = data.get("tool", {})
239
+ if isinstance(tool_section, dict) and "usecli" in tool_section:
240
+ usecli_section = tool_section.get("usecli")
241
+ if isinstance(usecli_section, dict):
242
+ return usecli_section
243
+
244
+ usecli_section = data.get("usecli", {})
245
+ if isinstance(usecli_section, dict):
246
+ return usecli_section
247
+
248
+ return {}
178
249
 
179
250
  def get(self, key: str, default: Any = None) -> Any:
180
251
  """Get a configuration value using dot notation.
@@ -255,10 +326,7 @@ class ConfigManager:
255
326
 
256
327
  @property
257
328
  def pyproject_exists(self) -> bool:
258
- """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)
329
+ return self.usecli_config_path.exists()
262
330
 
263
331
  @staticmethod
264
332
  def _load_project_version(path: Path) -> str | None:
@@ -304,18 +372,29 @@ def find_project_root(start_dir: Path | None = None) -> Path | None:
304
372
 
305
373
  current = start_dir.resolve()
306
374
 
375
+ git_root: Path | None = None
307
376
  while True:
308
- pyproject_path = current / "pyproject.toml"
377
+ pyproject_path = current / PYPROJECT_TOML
309
378
  if pyproject_path.exists():
310
379
  return current
311
380
 
381
+ usecli_path = current / USECLI_CONFIG_TOML
382
+ if usecli_path.exists():
383
+ return current
384
+
312
385
  git_dir = current / ".git"
313
386
  if git_dir.exists():
314
- return current
387
+ git_root = current
388
+ break
315
389
 
316
390
  parent = current.parent
317
391
  if parent == current:
318
392
  break
319
393
  current = parent
320
394
 
321
- 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