usecli 0.1.78__tar.gz → 0.1.79__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 (84) hide show
  1. {usecli-0.1.78 → usecli-0.1.79}/PKG-INFO +16 -2
  2. {usecli-0.1.78 → usecli-0.1.79}/README.md +15 -1
  3. {usecli-0.1.78 → usecli-0.1.79}/pyproject.toml +1 -1
  4. {usecli-0.1.78 → usecli-0.1.79}/pyproject.toml.orig +1 -1
  5. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/__init__.py +4 -2
  6. usecli-0.1.79/src/usecli/cli/commands/defaults/base/upgrade_command.py +316 -0
  7. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/templates/usecli.config.toml.j2 +3 -0
  8. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/entry.py +5 -2
  9. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/shared/config/manager.py +5 -2
  10. usecli-0.1.79/src/usecli/shared/upgrades/__init__.py +22 -0
  11. usecli-0.1.79/src/usecli/shared/upgrades/checker.py +276 -0
  12. usecli-0.1.79/src/usecli/shared/upgrades/discovery.py +287 -0
  13. usecli-0.1.79/src/usecli/shared/upgrades/installer.py +262 -0
  14. usecli-0.1.79/src/usecli/shared/upgrades/pyproject.py +619 -0
  15. usecli-0.1.79/src/usecli/shared/upgrades/service.py +47 -0
  16. {usecli-0.1.78 → usecli-0.1.79}/LICENSE +0 -0
  17. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/bundler/__init__.py +0 -0
  18. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/bundler/__main__.py +0 -0
  19. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/__init__.py +0 -0
  20. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/README.md +0 -0
  21. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/__init__.py +0 -0
  22. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/custom/README.md +0 -0
  23. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/custom/__init__.py +0 -0
  24. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/__init__.py +0 -0
  25. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
  26. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
  27. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
  28. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
  29. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
  30. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
  31. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
  32. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
  33. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
  34. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/make/make_bundle_command.py +0 -0
  35. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
  36. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
  37. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/commands/init_command.py +0 -0
  38. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/config/__init__.py +0 -0
  39. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/config/colors.py +0 -0
  40. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/__init__.py +0 -0
  41. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/base_command.py +0 -0
  42. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/error/__init__.py +0 -0
  43. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/error/handler.py +0 -0
  44. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/error/utils.py +0 -0
  45. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/exceptions/__init__.py +0 -0
  46. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/exceptions/base.py +0 -0
  47. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/exceptions/config.py +0 -0
  48. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/exceptions/usage.py +0 -0
  49. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/exceptions/validation.py +0 -0
  50. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/runtime.py +0 -0
  51. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/ui/__init__.py +0 -0
  52. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/ui/list.py +0 -0
  53. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/ui/title.py +0 -0
  54. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/ui/title.txt +0 -0
  55. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/validators/__init__.py +0 -0
  56. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/validators/network.py +0 -0
  57. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/validators/numeric.py +0 -0
  58. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/validators/path.py +0 -0
  59. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/core/validators/string.py +0 -0
  60. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/services/__init__.py +0 -0
  61. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/services/command_service.py +0 -0
  62. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/templates/command.py.j2 +0 -0
  63. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/templates/theme.toml.j2 +0 -0
  64. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/ayu_dark.toml +0 -0
  65. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
  66. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
  67. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
  68. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
  69. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/default.toml +0 -0
  70. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/dracula.toml +0 -0
  71. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
  72. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/nord.toml +0 -0
  73. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/themes/tokyo_night.toml +0 -0
  74. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/utils/__init__.py +0 -0
  75. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/utils/interactive/__init__.py +0 -0
  76. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
  77. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/menu.py +0 -0
  78. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/params.py +0 -0
  79. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/progress.py +0 -0
  80. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/shared/__init__.py +0 -0
  81. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/shared/config/__init__.py +0 -0
  82. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/shared/config/globals.py +0 -0
  83. {usecli-0.1.78 → usecli-0.1.79}/src/usecli/ui.py +0 -0
  84. {usecli-0.1.78 → usecli-0.1.79}/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.78
3
+ Version: 0.1.79
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>
@@ -245,6 +245,7 @@ about Show app info
245
245
  help Show help
246
246
  init Initialize usecli (usecli only)
247
247
  inspire Random quote
248
+ upgrade Upgrade the application to the latest version
248
249
  make:command Create new command (usecli only)
249
250
  make:theme Create new theme (usecli only)
250
251
  make:bundle Build a standalone executable with PyInstaller (usecli only)
@@ -289,9 +290,14 @@ file is always named `usecli.config.toml`):
289
290
  # main.py
290
291
  from usecli import run
291
292
 
292
- if __name__ == "__main__":
293
+
294
+ def main():
293
295
  run() # auto-detect project config
294
296
  # run("path/to/usecli.config.toml") # or point at a specific config
297
+
298
+
299
+ if __name__ == "__main__":
300
+ main()
295
301
  ```
296
302
 
297
303
  Run it in development (no PyInstaller required):
@@ -300,6 +306,14 @@ Run it in development (no PyInstaller required):
300
306
  uv run python main.py --help
301
307
  ```
302
308
 
309
+ To expose the same entry point as a console script, point `[project.scripts]`
310
+ at the `main` function in your `myapp` package (the usual `src/myapp` layout):
311
+
312
+ ```toml
313
+ [project.scripts]
314
+ myapp = "myapp:main"
315
+ ```
316
+
303
317
  Because `run()` injects the located config explicitly, a custom `main.py` works
304
318
  regardless of whether your CLI also has a `[project.scripts]` entry.
305
319
 
@@ -215,6 +215,7 @@ about Show app info
215
215
  help Show help
216
216
  init Initialize usecli (usecli only)
217
217
  inspire Random quote
218
+ upgrade Upgrade the application to the latest version
218
219
  make:command Create new command (usecli only)
219
220
  make:theme Create new theme (usecli only)
220
221
  make:bundle Build a standalone executable with PyInstaller (usecli only)
@@ -259,9 +260,14 @@ file is always named `usecli.config.toml`):
259
260
  # main.py
260
261
  from usecli import run
261
262
 
262
- if __name__ == "__main__":
263
+
264
+ def main():
263
265
  run() # auto-detect project config
264
266
  # run("path/to/usecli.config.toml") # or point at a specific config
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
265
271
  ```
266
272
 
267
273
  Run it in development (no PyInstaller required):
@@ -270,6 +276,14 @@ Run it in development (no PyInstaller required):
270
276
  uv run python main.py --help
271
277
  ```
272
278
 
279
+ To expose the same entry point as a console script, point `[project.scripts]`
280
+ at the `main` function in your `myapp` package (the usual `src/myapp` layout):
281
+
282
+ ```toml
283
+ [project.scripts]
284
+ myapp = "myapp:main"
285
+ ```
286
+
273
287
  Because `run()` injects the located config explicitly, a custom `main.py` works
274
288
  regardless of whether your CLI also has a `[project.scripts]` entry.
275
289
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usecli"
3
- version = "0.1.78"
3
+ version = "0.1.79"
4
4
  description = "A powerful Python CLI framework for building beautiful, developer-friendly command-line tools."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usecli"
3
- version = "0.1.78"
3
+ version = "0.1.79"
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" }]
@@ -274,7 +274,7 @@ def _ensure_cli_initialized() -> None:
274
274
  fail(error.__class__.__name__, str(error), 1)
275
275
 
276
276
  def invoke(self, ctx):
277
- from click.exceptions import Exit
277
+ from click.exceptions import ClickException, Exit
278
278
 
279
279
  from usecli.cli.core.runtime import is_json_mode
280
280
 
@@ -306,7 +306,9 @@ def _ensure_cli_initialized() -> None:
306
306
  styled_error = UsecliUsageError(e.message, ctx=e.ctx)
307
307
  styled_error.show()
308
308
  sys.exit(styled_error.exit_code)
309
- except _TyperClickException as e:
309
+ # UsecliError extends real-click ClickException, not the vendored
310
+ # typer._click one — catch both, mirroring the JSON path.
311
+ except (_TyperClickException, ClickException) as e:
310
312
  if hasattr(e, "show"):
311
313
  e.show()
312
314
  sys.exit(e.exit_code if hasattr(e, "exit_code") else 1)
@@ -0,0 +1,316 @@
1
+ """UpgradeCommand - upgrade the running application to the latest version.
2
+
3
+ A built-in default command available in every generated CLI. Installation
4
+ discovery, checking, and mutation are delegated to
5
+ ``usecli.shared.upgrades.UpgradeService``; this command owns only the
6
+ interaction: flag handling, confirmation, and output formatting.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated, Any
12
+
13
+ import typer
14
+
15
+ from usecli.cli.config.colors import COLOR
16
+ from usecli.cli.core.base_command import BaseCommand
17
+ from usecli.cli.core.exceptions.base import UsecliError
18
+ from usecli.shared.config.manager import get_config
19
+ from usecli.shared.upgrades import UpgradeService
20
+ from usecli.shared.upgrades.checker import UpgradeStatus
21
+ from usecli.shared.upgrades.discovery import InstallInfo
22
+ from usecli.ui import Confirm
23
+
24
+
25
+ class _LazyConsole:
26
+ _console: Any | None = None
27
+
28
+ def _get_console(self) -> Any:
29
+ if self._console is None:
30
+ from rich.console import Console
31
+
32
+ self._console = Console()
33
+ return self._console
34
+
35
+ def __getattr__(self, name: str) -> Any:
36
+ return getattr(self._get_console(), name)
37
+
38
+
39
+ console = _LazyConsole()
40
+
41
+
42
+ def _short_commit(commit: str | None) -> str:
43
+ """Render a commit id for display; version labels pass through intact."""
44
+ if not commit:
45
+ return "-"
46
+ import re
47
+
48
+ if re.fullmatch(r"[0-9a-fA-F]{8,40}", commit):
49
+ return commit[:7]
50
+ return commit
51
+
52
+
53
+ class UpgradeCommand(BaseCommand):
54
+ """Upgrade the running application from its original install source."""
55
+
56
+ def visible(self) -> bool:
57
+ config = get_config()
58
+ return bool(config.get("upgrade.enabled", True))
59
+
60
+ def signature(self) -> str:
61
+ return "upgrade"
62
+
63
+ def description(self) -> str:
64
+ return "Upgrade the application to the latest version"
65
+
66
+ def handle(
67
+ self,
68
+ check: Annotated[
69
+ bool,
70
+ typer.Option(
71
+ "--check",
72
+ help="Only check for updates; do not install anything.",
73
+ ),
74
+ ] = False,
75
+ force: Annotated[
76
+ bool,
77
+ typer.Option(
78
+ "--force",
79
+ help=(
80
+ "Upgrade even when already up to date and skip the "
81
+ "confirmation prompt."
82
+ ),
83
+ ),
84
+ ] = False,
85
+ ) -> dict[str, object]:
86
+ """Handle the command execution."""
87
+ from usecli.cli.core.runtime import is_json_mode
88
+
89
+ config = get_config()
90
+ install = UpgradeService.discover(config)
91
+ status = UpgradeService.check(install, config)
92
+
93
+ data: dict[str, object] = {
94
+ "mode": "check" if check else "apply",
95
+ "package": install.package,
96
+ "current": install.version,
97
+ "latest": status.latest,
98
+ "source": install.source,
99
+ "source_detail": status.detail,
100
+ "installer": install.installer,
101
+ "url": install.url,
102
+ "revision": install.revision,
103
+ "commit": install.commit,
104
+ "pinned": install.pinned,
105
+ "pinned_reason": install.pinned_reason,
106
+ "frozen": install.frozen,
107
+ "update_available": status.update_available,
108
+ "upgraded": None,
109
+ "error": status.error,
110
+ "latest_tag": status.latest_tag,
111
+ "latest_tag_commit": status.latest_tag_commit,
112
+ "note": status.note,
113
+ "pyproject_path": None,
114
+ "pyproject_previous_version": None,
115
+ "pyproject_new_version": None,
116
+ "pyproject_updated": None,
117
+ "pyproject_reason": None,
118
+ }
119
+
120
+ if check:
121
+ if status.error:
122
+ raise UsecliError(
123
+ status.error,
124
+ suggestion="Check your network connection and try again.",
125
+ )
126
+ if is_json_mode():
127
+ return data
128
+ self._print_check(status)
129
+ return data
130
+
131
+ # Mutations are impossible for these installations; fail loudly.
132
+ if (
133
+ install.frozen
134
+ or install.pinned
135
+ or install.source
136
+ in (
137
+ "editable",
138
+ "unknown",
139
+ )
140
+ ):
141
+ raise UsecliError(
142
+ self._blocked_message(install),
143
+ suggestion=self._blocked_suggestion(install),
144
+ )
145
+
146
+ if status.error is None and not status.update_available and not force:
147
+ data["upgraded"] = False
148
+ data["message"] = "Already up to date."
149
+ if is_json_mode():
150
+ return data
151
+ console.print(
152
+ f"[{COLOR.INFO}]Already up to date ({install.version}).[/{COLOR.INFO}]"
153
+ )
154
+ return data
155
+
156
+ if not is_json_mode() and not force:
157
+ confirmed = Confirm.ask(
158
+ f"[{COLOR.WARNING}]Upgrade {install.package} from "
159
+ f"{install.version} to "
160
+ f"{_short_commit(status.latest) if status.latest else 'latest'}?"
161
+ f"[/{COLOR.WARNING}]",
162
+ default=False,
163
+ )
164
+ if not confirmed:
165
+ data["upgraded"] = False
166
+ data["message"] = "Aborted."
167
+ console.print(
168
+ f"[{COLOR.INFO}]Aborted. Re-run with --force to skip "
169
+ f"confirmation.[/{COLOR.INFO}]"
170
+ )
171
+ return data
172
+
173
+ result = self._run_upgrade(install, config, status)
174
+ data["upgraded"] = result.success
175
+ data["message"] = result.message
176
+
177
+ if result.pyproject is not None:
178
+ data["pyproject_path"] = result.pyproject.path
179
+ data["pyproject_previous_version"] = result.pyproject.previous_version
180
+ data["pyproject_new_version"] = result.pyproject.new_version
181
+ data["pyproject_updated"] = result.pyproject.updated
182
+ data["pyproject_reason"] = result.pyproject.reason
183
+
184
+ if not result.success:
185
+ raise UsecliError(
186
+ result.message or "Upgrade failed.",
187
+ suggestion="Run the upgrade command manually.",
188
+ )
189
+
190
+ if is_json_mode():
191
+ return data
192
+
193
+ console.print(f"[{COLOR.SUCCESS}]✓ Upgrade complete.[/{COLOR.SUCCESS}]")
194
+
195
+ if result.pyproject is not None:
196
+ if result.pyproject.updated:
197
+ console.print(result.pyproject.summary)
198
+ elif result.pyproject.reason:
199
+ console.print(
200
+ f"[{COLOR.WARNING}]⚠ {result.pyproject.reason}[/{COLOR.WARNING}]"
201
+ )
202
+
203
+ from usecli.cli.core.ui.title import get_project_name
204
+
205
+ console.print(
206
+ f"Run `[bold {COLOR.PRIMARY}]{get_project_name()} --version"
207
+ f"[/bold {COLOR.PRIMARY}]` to verify."
208
+ )
209
+ return data
210
+
211
+ def _run_upgrade(
212
+ self,
213
+ install: InstallInfo,
214
+ config: Any,
215
+ status: UpgradeStatus,
216
+ ):
217
+ """Run the upgrade behind a spinner (auto-disabled in JSON mode)."""
218
+ from usecli import Spinner
219
+
220
+ target = status.latest or "latest"
221
+ with Spinner(f"Upgrading {install.package}") as spinner:
222
+ spinner.update(
223
+ f"Upgrading {install.package} ({install.version} → {target})"
224
+ )
225
+ return UpgradeService.upgrade(
226
+ install,
227
+ config,
228
+ target_revision=status.latest_tag,
229
+ )
230
+
231
+ def _print_check(self, status: UpgradeStatus) -> None:
232
+ """Render the non-mutating check report."""
233
+ install = status.install
234
+ console.print()
235
+ console.print(f"[bold {COLOR.PRIMARY}]Upgrade Check[/bold {COLOR.PRIMARY}]")
236
+ console.print(f"[{COLOR.PRIMARY}]─" * 78)
237
+ self._print_row("Package", install.package or "unknown")
238
+ self._print_row("Installed", install.version)
239
+ self._print_row("Source", status.detail or install.source)
240
+ if install.source == "git":
241
+ if install.revision:
242
+ self._print_row("Ref", install.revision)
243
+ self._print_row("Commit", _short_commit(install.commit))
244
+ if status.latest:
245
+ self._print_row("Latest", _short_commit(status.latest))
246
+ elif status.latest:
247
+ self._print_row("Latest", status.latest)
248
+ self._print_row("Installer", install.installer or "unknown")
249
+ console.print()
250
+
251
+ if install.pinned:
252
+ console.print(
253
+ f"[{COLOR.WARNING}]This installation is pinned "
254
+ f"({install.pinned_reason}); no automatic upgrade."
255
+ f"[/{COLOR.WARNING}]"
256
+ )
257
+ elif status.update_available:
258
+ from usecli.cli.core.ui.title import get_project_name
259
+
260
+ console.print(
261
+ f"[{COLOR.SUCCESS}]Update available: {install.version} → "
262
+ f"{_short_commit(status.latest)}. Run "
263
+ f"`{get_project_name()} upgrade` to update.[/{COLOR.SUCCESS}]"
264
+ )
265
+ else:
266
+ if status.note:
267
+ console.print(f"[{COLOR.INFO}]{status.note}[/{COLOR.INFO}]")
268
+ else:
269
+ console.print(f"[{COLOR.INFO}]You are up to date.[/{COLOR.INFO}]")
270
+ console.print()
271
+
272
+ def _print_row(self, label: str, value: str) -> None:
273
+ visible_value = console.render_str(value).plain
274
+ right_align_column = 76
275
+ indent_width = 2
276
+ padding_spaces = 2
277
+ dots_length = (
278
+ right_align_column
279
+ - indent_width
280
+ - len(label)
281
+ - padding_spaces
282
+ - len(visible_value)
283
+ )
284
+ dots = "." * max(dots_length, 1)
285
+ console.print(
286
+ f" [{COLOR.FOREGROUND}]{label}[/{COLOR.FOREGROUND}] {dots} {value}"
287
+ )
288
+
289
+ def _blocked_message(self, install: InstallInfo) -> str:
290
+ if install.frozen:
291
+ return "This application runs from a frozen bundle and cannot self-upgrade."
292
+ if install.source == "editable":
293
+ return "This is an editable install and cannot self-upgrade."
294
+ if install.pinned:
295
+ return (
296
+ f"This installation is pinned ({install.pinned_reason}) and "
297
+ "will not automatically upgrade."
298
+ )
299
+ return (
300
+ "Could not determine how this application was installed; "
301
+ "automatic upgrade is unavailable."
302
+ )
303
+
304
+ def _blocked_suggestion(self, install: InstallInfo) -> str:
305
+ if install.frozen:
306
+ return "Download the latest release or rebuild the bundle."
307
+ if install.source == "editable":
308
+ return "Update the source repository and re-sync (e.g. `uv sync`)."
309
+ if install.source == "git" and install.url:
310
+ revision = f"@{install.revision}" if install.revision else ""
311
+ return (
312
+ "Reinstall from the source you want, e.g. "
313
+ f'`uv tool install --force "{install.package} @ '
314
+ f'git+{install.url}{revision}"`.'
315
+ )
316
+ return "Reinstall the package with your package manager to upgrade."
@@ -9,3 +9,6 @@ templates_dir = "{{ templates_dir | default('cli/templates') }}"
9
9
  themes_dir = "{{ themes_dir | default('cli/themes') }}"
10
10
  theme = "{{ theme | default('default') }}"
11
11
  hide_inspire = {{ hide_inspire | default(false) | lower }}
12
+
13
+ [usecli.upgrade]
14
+ enabled = {{ upgrade_enabled | default(true) | lower }}
@@ -5,9 +5,12 @@ thin ``main.py``::
5
5
 
6
6
  from usecli import run
7
7
 
8
- if __name__ == "__main__":
8
+ def main():
9
9
  run() # auto-detect the project config
10
- run("my/custom/cli.toml") # or point at a specific config
10
+ # run("my/custom/cli.toml") # or point at a specific config
11
+
12
+ if __name__ == "__main__":
13
+ main()
11
14
 
12
15
  It works in three contexts:
13
16
 
@@ -284,6 +284,8 @@ class ConfigManager:
284
284
  "environment": "prod",
285
285
  "command_name": "usecli",
286
286
  "hide_inspire": False,
287
+ # Users disable the built-in `upgrade` command via [usecli.upgrade].
288
+ "upgrade": {"enabled": True},
287
289
  }
288
290
 
289
291
  def __init__(
@@ -320,7 +322,8 @@ class ConfigManager:
320
322
  # find_project_root discovery (up-walk + bounded rglob tree search) that
321
323
  # would otherwise be spent rediscovering it. Derive the project root
322
324
  # from the config's own directory instead.
323
- if usecli_config_path is None:
325
+ # Unreachable: the normalization above guarantees a Path here.
326
+ if usecli_config_path is None: # pragma: no cover
324
327
  detected_root = find_project_root(start_dir)
325
328
  else:
326
329
  detected_root = None
@@ -328,7 +331,7 @@ class ConfigManager:
328
331
  config_parent = self.usecli_config_path.parent
329
332
  if detected_root is None:
330
333
  detected_root = config_parent
331
- else:
334
+ else: # pragma: no cover - detected_root is always None here
332
335
  root_config = detected_root / USECLI_CONFIG_TOML
333
336
  if self.usecli_config_path.resolve() != root_config.resolve():
334
337
  detected_root = config_parent
@@ -0,0 +1,22 @@
1
+ """Reusable self-upgrade machinery for usecli applications.
2
+
3
+ Every generated CLI inherits a built-in ``upgrade`` command built on this
4
+ package. Installations are classified from standard packaging metadata
5
+ (``INSTALLER`` and PEP 610 ``direct_url.json``) — no CLI-author
6
+ configuration required.
7
+ """
8
+
9
+ from usecli.shared.upgrades.checker import UpgradeStatus
10
+ from usecli.shared.upgrades.discovery import InstallInfo, InstallSource
11
+ from usecli.shared.upgrades.installer import UpgradeResult
12
+ from usecli.shared.upgrades.pyproject import PyprojectUpdate
13
+ from usecli.shared.upgrades.service import UpgradeService
14
+
15
+ __all__ = [
16
+ "InstallInfo",
17
+ "InstallSource",
18
+ "PyprojectUpdate",
19
+ "UpgradeResult",
20
+ "UpgradeService",
21
+ "UpgradeStatus",
22
+ ]