usecli 0.1.44__tar.gz → 0.1.46__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 (71) hide show
  1. {usecli-0.1.44 → usecli-0.1.46}/PKG-INFO +1 -1
  2. {usecli-0.1.44 → usecli-0.1.46}/pyproject.toml +1 -1
  3. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/init_command.py +58 -7
  4. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/config/colors.py +260 -14
  5. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/ui/title.py +3 -2
  6. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/shared/config/manager.py +8 -1
  7. {usecli-0.1.44 → usecli-0.1.46}/LICENSE +0 -0
  8. {usecli-0.1.44 → usecli-0.1.46}/README.md +0 -0
  9. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/__init__.py +0 -0
  10. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/__init__.py +0 -0
  11. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/README.md +0 -0
  12. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/__init__.py +0 -0
  13. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/custom/README.md +0 -0
  14. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/custom/__init__.py +0 -0
  15. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/__init__.py +0 -0
  16. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
  17. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
  18. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
  19. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
  20. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
  21. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
  22. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
  23. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
  24. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
  25. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
  26. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
  27. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/config/__init__.py +0 -0
  28. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/__init__.py +0 -0
  29. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/base_command.py +0 -0
  30. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/error/__init__.py +0 -0
  31. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/error/handler.py +0 -0
  32. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/error/utils.py +0 -0
  33. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/exceptions/__init__.py +0 -0
  34. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/exceptions/base.py +0 -0
  35. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/exceptions/config.py +0 -0
  36. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/exceptions/usage.py +0 -0
  37. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/exceptions/validation.py +0 -0
  38. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/skill_generator.py +0 -0
  39. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/ui/__init__.py +0 -0
  40. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/ui/list.py +0 -0
  41. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/ui/title.txt +0 -0
  42. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/validators/__init__.py +0 -0
  43. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/validators/network.py +0 -0
  44. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/validators/numeric.py +0 -0
  45. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/validators/path.py +0 -0
  46. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/core/validators/string.py +0 -0
  47. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/services/__init__.py +0 -0
  48. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/services/command_service.py +0 -0
  49. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/templates/command.py.j2 +0 -0
  50. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/templates/theme.toml.j2 +0 -0
  51. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/templates/usecli.config.toml.j2 +0 -0
  52. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/ayu_dark.toml +0 -0
  53. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
  54. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
  55. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
  56. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
  57. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/default.toml +0 -0
  58. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/dracula.toml +0 -0
  59. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
  60. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/nord.toml +0 -0
  61. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/themes/tokyo_night.toml +0 -0
  62. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/utils/__init__.py +0 -0
  63. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/utils/interactive/__init__.py +0 -0
  64. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
  65. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/menu.py +0 -0
  66. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/params.py +0 -0
  67. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/shared/__init__.py +0 -0
  68. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/shared/config/__init__.py +0 -0
  69. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/shared/config/globals.py +0 -0
  70. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/ui.py +0 -0
  71. {usecli-0.1.44 → usecli-0.1.46}/src/usecli/usecli.config.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usecli
3
- Version: 0.1.44
3
+ Version: 0.1.46
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.44"
3
+ version = "0.1.46"
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" }]
@@ -103,15 +103,31 @@ class InitCommand(BaseCommand):
103
103
  if not should_overwrite:
104
104
  return "skipped"
105
105
 
106
+ config_path.parent.mkdir(parents=True, exist_ok=True)
106
107
  config_path.write_text(config_content.rstrip() + "\n")
107
108
  return "updated" if existed else "created"
108
109
 
110
+ def _should_skip_config_path(self, path: Path) -> bool:
111
+ try:
112
+ resolved = path.resolve()
113
+ except OSError:
114
+ resolved = path
115
+ if any(part in ConfigManager._SKIP_DIRS for part in resolved.parts):
116
+ return True
117
+ try:
118
+ resolved.relative_to(Path(sys.prefix).resolve())
119
+ except ValueError:
120
+ return False
121
+ return True
122
+
109
123
  def _resolve_config_path(self, value: str, project_root: Path) -> Path:
110
124
  path = Path(value).expanduser()
111
125
  if not path.is_absolute():
112
126
  path = project_root / path
113
127
  if path.exists() and path.is_dir():
114
128
  return (path / USECLI_CONFIG_TOML).resolve()
129
+ if not path.exists() and path.suffix == "":
130
+ return (path / USECLI_CONFIG_TOML).resolve()
115
131
  return path.resolve()
116
132
 
117
133
  def _ensure_project_scripts(
@@ -180,6 +196,36 @@ class InitCommand(BaseCommand):
180
196
 
181
197
  return created
182
198
 
199
+ def _find_project_root_for_init(self, start_dir: Path) -> Path:
200
+ current = start_dir.resolve()
201
+ git_root: Path | None = None
202
+ while True:
203
+ if (current / "pyproject.toml").exists():
204
+ return current
205
+ if (current / USECLI_CONFIG_TOML).exists():
206
+ return current
207
+ git_dir = current / ".git"
208
+ if git_dir.exists():
209
+ git_root = current
210
+ break
211
+ parent = current.parent
212
+ if parent == current:
213
+ break
214
+ current = parent
215
+ return (git_root or start_dir).resolve()
216
+
217
+ def _find_pyproject_path_for_init(self, start_dir: Path) -> Path | None:
218
+ current = start_dir.resolve()
219
+ while True:
220
+ pyproject_path = current / "pyproject.toml"
221
+ if pyproject_path.exists():
222
+ return pyproject_path
223
+ parent = current.parent
224
+ if parent == current:
225
+ break
226
+ current = parent
227
+ return None
228
+
183
229
  def _derive_templates_dir(self, commands_dir: str) -> str:
184
230
  commands_path = Path(commands_dir)
185
231
  parent = commands_path.parent
@@ -503,12 +549,9 @@ include = ["{root_package}*"]
503
549
  ),
504
550
  ) -> None:
505
551
  cwd = Path.cwd()
506
- config_manager = ConfigManager(start_dir=cwd)
507
- project_root = config_manager.get_project_root()
508
- pyproject_path = (
509
- config_manager.pyproject_path
510
- if config_manager.pyproject_path.exists()
511
- else project_root / "pyproject.toml"
552
+ project_root = self._find_project_root_for_init(cwd)
553
+ pyproject_path = self._find_pyproject_path_for_init(cwd) or (
554
+ project_root / "pyproject.toml"
512
555
  )
513
556
 
514
557
  console.print()
@@ -706,12 +749,20 @@ include = ["{root_package}*"]
706
749
  config_root = project_root
707
750
  if commands_path.parent != project_root:
708
751
  config_root = commands_path.parent
709
- existing_config = ConfigManager(start_dir=config_root).usecli_config_path
752
+ existing_config = ConfigManager._find_usecli_config_in_tree(
753
+ project_root,
754
+ config_root,
755
+ skip_venv=True,
756
+ )
757
+ if existing_config is None or self._should_skip_config_path(existing_config):
758
+ existing_config = config_root / USECLI_CONFIG_TOML
710
759
  default_config_path = (
711
760
  existing_config
712
761
  if existing_config.exists()
713
762
  else config_root / USECLI_CONFIG_TOML
714
763
  )
764
+ if self._should_skip_config_path(default_config_path):
765
+ default_config_path = config_root / USECLI_CONFIG_TOML
715
766
  config_location = Prompt.ask(
716
767
  f"[bold {COLOR.SECONDARY}]Config file location[/bold {COLOR.SECONDARY}]"
717
768
  " (path or directory)",
@@ -15,8 +15,9 @@ import importlib.metadata
15
15
  import importlib.util
16
16
  import os
17
17
  import sys
18
+ import time
18
19
  from pathlib import Path
19
- from typing import Any, Callable, Final, final
20
+ from typing import Any, Callable, Final, Protocol, cast, final
20
21
 
21
22
  if sys.version_info >= (3, 11):
22
23
  import tomllib
@@ -99,6 +100,110 @@ def _find_usecli_config_path(
99
100
  return selection[0]
100
101
 
101
102
 
103
+ def _get_command_name() -> str | None:
104
+ """Get the current command name from sys.argv."""
105
+ if not sys.argv:
106
+ return None
107
+ command = os.path.basename(sys.argv[0])
108
+ return command if command else None
109
+
110
+
111
+ def _get_console_script_aliases(command_name: str | None) -> set[str]:
112
+ """Get all aliases for a console script from package metadata."""
113
+ if not command_name:
114
+ return set()
115
+ aliases: set[str] = {command_name}
116
+ try:
117
+ distributions = importlib.metadata.distributions()
118
+ except Exception:
119
+ return aliases
120
+ for dist in distributions:
121
+ try:
122
+ entry_points = dist.entry_points
123
+ except Exception:
124
+ continue
125
+ names = [
126
+ entry_point.name
127
+ for entry_point in entry_points
128
+ if entry_point.group == "console_scripts"
129
+ ]
130
+ if command_name in names:
131
+ aliases.update(names)
132
+ break
133
+ return aliases
134
+
135
+
136
+ def _config_matches_command(path: Path, command_name: str | None) -> bool:
137
+ """Check if a config file matches the given command name."""
138
+ if command_name is None:
139
+ return True
140
+ try:
141
+ data = _load_usecli_config_file(path)
142
+ except (tomllib.TOMLDecodeError, OSError):
143
+ return True
144
+ config_command = data.get("command_name")
145
+ if not isinstance(config_command, str):
146
+ return True
147
+ normalized = config_command.strip()
148
+ if not normalized:
149
+ return True
150
+ if normalized == command_name:
151
+ return True
152
+ aliases = _get_console_script_aliases(command_name)
153
+ return normalized in aliases
154
+
155
+
156
+ def _find_usecli_config_path_for_command(
157
+ root_dir: Path, start_dir: Path, *, skip_venv: bool
158
+ ) -> Path | None:
159
+ """Find usecli config that matches the current command."""
160
+ if not root_dir.exists() or not root_dir.is_dir():
161
+ return None
162
+
163
+ candidates = [path for path in root_dir.rglob(USECLI_CONFIG_TOML)]
164
+ if skip_venv:
165
+ candidates = [
166
+ path
167
+ for path in candidates
168
+ if not any(part in _SKIP_DIRS for part in path.parts)
169
+ ]
170
+ if not candidates:
171
+ return None
172
+
173
+ command_name = _get_command_name()
174
+
175
+ # Filter candidates by command_name matching
176
+ if command_name:
177
+ candidates = [
178
+ path for path in candidates if _config_matches_command(path, command_name)
179
+ ]
180
+
181
+ if not candidates:
182
+ return None
183
+
184
+ start_dir = start_dir.resolve()
185
+ preferred: list[Path] = []
186
+ for path in candidates:
187
+ try:
188
+ path.relative_to(start_dir)
189
+ preferred.append(path)
190
+ except ValueError:
191
+ continue
192
+
193
+ selection = preferred or candidates
194
+
195
+ def _depth_key(path: Path) -> tuple[int, str]:
196
+ try:
197
+ relative = path.relative_to(start_dir)
198
+ return (len(relative.parts), str(path))
199
+ except ValueError:
200
+ relative = path.relative_to(root_dir)
201
+ return (len(relative.parts), str(path))
202
+
203
+ selection.sort(key=_depth_key)
204
+ return selection[0]
205
+
206
+
102
207
  def _find_usecli_config_in_package() -> Path | None:
103
208
  spec = importlib.util.find_spec(_get_package_name())
104
209
  if spec is None or not spec.submodule_search_locations:
@@ -233,13 +338,15 @@ def _find_project_root(start_dir: Path | None = None) -> Path | None:
233
338
  return git_root
234
339
 
235
340
 
236
- def _load_usecli_config(project_root: Path | None) -> dict[str, Any]:
341
+ def _load_usecli_config(
342
+ project_root: Path | None,
343
+ ) -> tuple[dict[str, Any], Path | None]:
237
344
  if project_root is None:
238
- return {}
345
+ return {}, None
239
346
 
240
347
  config_path = project_root / USECLI_CONFIG_TOML
241
348
  if not config_path.exists():
242
- config_path = _find_usecli_config_path(
349
+ config_path = _find_usecli_config_path_for_command(
243
350
  project_root,
244
351
  project_root,
245
352
  skip_venv=True,
@@ -247,14 +354,14 @@ def _load_usecli_config(project_root: Path | None) -> dict[str, Any]:
247
354
  if not config_path or not config_path.exists():
248
355
  console_match = _find_usecli_config_for_console_script()
249
356
  if console_match:
250
- return _load_usecli_config_file(console_match)
357
+ return _load_usecli_config_file(console_match), console_match
251
358
  package_match = _find_usecli_config_in_package()
252
359
  if package_match:
253
360
  config_path = package_match
254
361
  if not config_path or not config_path.exists():
255
- return {}
362
+ return {}, None
256
363
 
257
- return _load_usecli_config_file(config_path)
364
+ return _load_usecli_config_file(config_path), config_path
258
365
 
259
366
 
260
367
  def _load_usecli_config_file(config_path: Path) -> dict[str, Any]:
@@ -461,16 +568,16 @@ def _load_theme_file(theme_path: Path) -> dict[str, Any]:
461
568
  return data
462
569
 
463
570
 
464
- def _load_theme() -> tuple[dict[str, str], dict[str, str]]:
571
+ def _load_theme() -> tuple[dict[str, str], dict[str, str], str, Path | None]:
465
572
  project_root = _find_project_root()
466
- config = _load_usecli_config(project_root)
573
+ config_values, _ = _load_usecli_config(project_root)
467
574
 
468
575
  theme_name = DEFAULT_THEME_NAME
469
- config_theme = config.get("theme")
576
+ config_theme = config_values.get("theme")
470
577
  if isinstance(config_theme, str) and config_theme.strip():
471
578
  theme_name = config_theme.strip()
472
579
 
473
- theme_path = _resolve_theme_path(theme_name, project_root, config)
580
+ theme_path = _resolve_theme_path(theme_name, project_root, config_values)
474
581
  theme_data = _load_theme_file(theme_path) if theme_path else {}
475
582
 
476
583
  colors = _merge_theme_values(
@@ -480,14 +587,153 @@ def _load_theme() -> tuple[dict[str, str], dict[str, str]]:
480
587
  )
481
588
  ansi = _build_ansi_palette(colors)
482
589
 
483
- return colors, ansi
590
+ return colors, ansi, theme_name, theme_path
591
+
592
+
593
+ _THEME_CACHE: dict[str, Any] = {
594
+ "context": None,
595
+ "cwd": None,
596
+ "config_path": None,
597
+ "config_sig": None,
598
+ "last_checked": 0.0,
599
+ }
600
+
601
+
602
+ def _config_signature(path: Path) -> tuple[Path, int | None, int | None]:
603
+ try:
604
+ stat = path.stat()
605
+ except OSError:
606
+ return (path.resolve(), None, None)
607
+ return (path.resolve(), int(stat.st_mtime), int(stat.st_size))
608
+
609
+
610
+ def _compute_theme_context() -> tuple[Path | None, Path | None, str, Path | None]:
611
+ project_root = _find_project_root()
612
+ config_values, config_path = _load_usecli_config(project_root)
613
+ theme_name = DEFAULT_THEME_NAME
614
+ config_theme = config_values.get("theme")
615
+ if isinstance(config_theme, str) and config_theme.strip():
616
+ theme_name = config_theme.strip()
617
+ theme_path = _resolve_theme_path(theme_name, project_root, config_values)
618
+ return (
619
+ project_root.resolve() if project_root else None,
620
+ config_path.resolve() if config_path else None,
621
+ theme_name,
622
+ theme_path.resolve() if theme_path else None,
623
+ )
484
624
 
485
625
 
486
- _THEME_COLORS, _THEME_ANSI = _load_theme()
626
+ def _theme_context() -> tuple[Path | None, Path | None, str, Path | None]:
627
+ now = time.monotonic()
628
+ cached_context = _THEME_CACHE.get("context")
629
+ cached_cwd = _THEME_CACHE.get("cwd")
630
+ cached_sig = _THEME_CACHE.get("config_sig")
631
+ cached_path = _THEME_CACHE.get("config_path")
632
+
633
+ cwd = Path.cwd().resolve()
634
+ if cached_context is not None and cached_cwd == cwd:
635
+ if cached_path is None:
636
+ return cached_context
637
+ if now - float(_THEME_CACHE.get("last_checked", 0.0)) < 0.25:
638
+ return cached_context
639
+ _THEME_CACHE["last_checked"] = now
640
+ current_sig = _config_signature(cached_path)
641
+ if current_sig == cached_sig:
642
+ return cached_context
643
+
644
+ context = _compute_theme_context()
645
+ _THEME_CACHE["context"] = context
646
+ _THEME_CACHE["cwd"] = cwd
647
+ config_path = context[1]
648
+ _THEME_CACHE["config_path"] = config_path
649
+ _THEME_CACHE["config_sig"] = _config_signature(config_path) if config_path else None
650
+ _THEME_CACHE["last_checked"] = now
651
+ return context
652
+
653
+
654
+ class _AnsiNamespace(Protocol):
655
+ PRIMARY: str
656
+ SECONDARY: str
657
+ ACCENT: str
658
+ FOREGROUND: str
659
+ FOREGROUND_MUTED: str
660
+ RESET: str
661
+ RED: str
662
+ GREEN: str
663
+ YELLOW: str
664
+ BLUE: str
665
+
666
+
667
+ class _ColorNamespace(Protocol):
668
+ ANSI: type[_AnsiNamespace]
669
+
670
+
671
+ def _apply_theme(
672
+ color_class: _ColorNamespace, colors: dict[str, str], ansi: dict[str, str]
673
+ ) -> None:
674
+ for key in (
675
+ "primary",
676
+ "secondary",
677
+ "accent",
678
+ "success",
679
+ "error",
680
+ "warning",
681
+ "info",
682
+ "foreground",
683
+ "foreground_muted",
684
+ "background",
685
+ "border",
686
+ "border_focus",
687
+ "command",
688
+ "option",
689
+ "link",
690
+ "prompt",
691
+ "panel_primary",
692
+ "panel_secondary",
693
+ "panel_accent",
694
+ ):
695
+ setattr(color_class, key.upper(), colors[key])
696
+
697
+ ansi_class = color_class.ANSI
698
+ for key in (
699
+ "primary",
700
+ "secondary",
701
+ "accent",
702
+ "foreground",
703
+ "foreground_muted",
704
+ "reset",
705
+ "red",
706
+ "green",
707
+ "yellow",
708
+ "blue",
709
+ ):
710
+ setattr(ansi_class, key.upper(), ansi[key])
711
+
712
+
713
+ def _ensure_theme_loaded(color_class: type[Any]) -> None:
714
+ global _THEME_CONTEXT
715
+ context = _theme_context()
716
+ if context == _THEME_CONTEXT:
717
+ return
718
+ colors, ansi, _, _ = _load_theme()
719
+ _THEME_CONTEXT = context
720
+ _THEME_COLORS.update(colors)
721
+ _THEME_ANSI.update(ansi)
722
+ _apply_theme(cast(_ColorNamespace, color_class), _THEME_COLORS, _THEME_ANSI)
723
+
724
+
725
+ _THEME_COLORS, _THEME_ANSI, _THEME_NAME, _THEME_PATH = _load_theme()
726
+ _THEME_CONTEXT: tuple[Path | None, Path | None, str, Path | None] = _theme_context()
727
+
728
+
729
+ class _ColorMeta(type):
730
+ def __getattribute__(cls, name: str) -> Any:
731
+ _ensure_theme_loaded(cls)
732
+ return super().__getattribute__(name)
487
733
 
488
734
 
489
735
  @final
490
- class COLOR:
736
+ class COLOR(metaclass=_ColorMeta):
491
737
  """Semantic color system for usecli CLI.
492
738
 
493
739
  All colors are defined as hex color codes compatible with Rich console.
@@ -68,8 +68,9 @@ def get_project_name() -> str:
68
68
  # First, try to get the title from the config
69
69
  config = get_config()
70
70
  title = config.get("title")
71
- if title:
72
- return title
71
+ if config.has_key("title") and isinstance(title, str) and title.strip():
72
+ normalized = title.strip()
73
+ return "useCli" if normalized == "usecli" else normalized
73
74
 
74
75
  # Fall back to command name from pyproject.toml scripts
75
76
  command_name = _get_script_command_name(Path.cwd())
@@ -582,8 +582,15 @@ _config_manager: ConfigManager | None = None
582
582
  def get_config() -> ConfigManager:
583
583
  """Get the global ConfigManager instance."""
584
584
  global _config_manager
585
+ current_root = find_project_root(Path.cwd())
586
+ if current_root is None:
587
+ current_root = Path.cwd().resolve()
585
588
  if _config_manager is None:
586
- _config_manager = ConfigManager()
589
+ _config_manager = ConfigManager(start_dir=Path.cwd())
590
+ else:
591
+ cached_root = _config_manager.project_root.resolve()
592
+ if cached_root != current_root.resolve():
593
+ _config_manager = ConfigManager(start_dir=Path.cwd())
587
594
  return _config_manager
588
595
 
589
596
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes