usecli 0.1.78__tar.gz → 0.1.80__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 (85) hide show
  1. {usecli-0.1.78 → usecli-0.1.80}/PKG-INFO +45 -2
  2. {usecli-0.1.78 → usecli-0.1.80}/README.md +44 -1
  3. {usecli-0.1.78 → usecli-0.1.80}/pyproject.toml +1 -1
  4. {usecli-0.1.78 → usecli-0.1.80}/pyproject.toml.orig +1 -1
  5. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/__init__.py +4 -2
  6. usecli-0.1.80/src/usecli/cli/commands/defaults/base/upgrade_command.py +316 -0
  7. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/templates/usecli.config.toml.j2 +10 -0
  8. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/entry.py +5 -2
  9. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/shared/config/manager.py +13 -2
  10. usecli-0.1.80/src/usecli/shared/upgrades/__init__.py +22 -0
  11. usecli-0.1.80/src/usecli/shared/upgrades/checker.py +276 -0
  12. usecli-0.1.80/src/usecli/shared/upgrades/discovery.py +370 -0
  13. usecli-0.1.80/src/usecli/shared/upgrades/installer.py +262 -0
  14. usecli-0.1.80/src/usecli/shared/upgrades/pyproject.py +619 -0
  15. usecli-0.1.80/src/usecli/shared/upgrades/service.py +47 -0
  16. usecli-0.1.80/src/usecli/shared/upgrades/settings.py +129 -0
  17. {usecli-0.1.78 → usecli-0.1.80}/LICENSE +0 -0
  18. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/bundler/__init__.py +0 -0
  19. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/bundler/__main__.py +0 -0
  20. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/__init__.py +0 -0
  21. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/README.md +0 -0
  22. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/__init__.py +0 -0
  23. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/custom/README.md +0 -0
  24. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/custom/__init__.py +0 -0
  25. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/__init__.py +0 -0
  26. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
  27. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
  28. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
  29. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
  30. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
  31. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
  32. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
  33. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
  34. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
  35. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/make/make_bundle_command.py +0 -0
  36. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
  37. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
  38. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/init_command.py +0 -0
  39. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/config/__init__.py +0 -0
  40. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/config/colors.py +0 -0
  41. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/__init__.py +0 -0
  42. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/base_command.py +0 -0
  43. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/error/__init__.py +0 -0
  44. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/error/handler.py +0 -0
  45. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/error/utils.py +0 -0
  46. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/__init__.py +0 -0
  47. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/base.py +0 -0
  48. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/config.py +0 -0
  49. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/usage.py +0 -0
  50. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/validation.py +0 -0
  51. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/runtime.py +0 -0
  52. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/ui/__init__.py +0 -0
  53. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/ui/list.py +0 -0
  54. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/ui/title.py +0 -0
  55. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/ui/title.txt +0 -0
  56. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/__init__.py +0 -0
  57. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/network.py +0 -0
  58. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/numeric.py +0 -0
  59. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/path.py +0 -0
  60. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/string.py +0 -0
  61. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/services/__init__.py +0 -0
  62. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/services/command_service.py +0 -0
  63. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/templates/command.py.j2 +0 -0
  64. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/templates/theme.toml.j2 +0 -0
  65. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/ayu_dark.toml +0 -0
  66. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
  67. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
  68. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
  69. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
  70. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/default.toml +0 -0
  71. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/dracula.toml +0 -0
  72. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
  73. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/nord.toml +0 -0
  74. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/tokyo_night.toml +0 -0
  75. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/utils/__init__.py +0 -0
  76. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/utils/interactive/__init__.py +0 -0
  77. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
  78. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/menu.py +0 -0
  79. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/params.py +0 -0
  80. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/progress.py +0 -0
  81. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/shared/__init__.py +0 -0
  82. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/shared/config/__init__.py +0 -0
  83. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/shared/config/globals.py +0 -0
  84. {usecli-0.1.78 → usecli-0.1.80}/src/usecli/ui.py +0 -0
  85. {usecli-0.1.78 → usecli-0.1.80}/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.80
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,11 +245,41 @@ 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)
251
252
  ```
252
253
 
254
+ ### Upgrades
255
+
256
+ Every CLI built on usecli ships `upgrade`, which detects how the running
257
+ package was installed and delegates to the same package manager — `uv tool`,
258
+ `pipx`, or `pip` — instead of creating a second installation:
259
+
260
+ ```bash
261
+ mycli upgrade --check # show installed/latest versions and the source
262
+ mycli upgrade # confirm and install the latest release
263
+ mycli upgrade --force # skip the prompt and the up-to-date check
264
+ ```
265
+
266
+ The source comes from packaging metadata: PyPI for index installs, and the
267
+ Git remote for PEP 610 `direct_url.json` installs. Override it in
268
+ `usecli.config.toml` when metadata is not enough (private mirrors, missing
269
+ `direct_url.json`, a package name that differs from the command name):
270
+
271
+ ```toml
272
+ [usecli.upgrade]
273
+ enabled = true
274
+ source = "git" # "auto" (default) | "pypi" | "git"
275
+ package = "mycli" # optional
276
+ url = "https://github.com/owner/mycli.git" # required for "git" unless detected
277
+ ref = "main" # branch, tag, or commit
278
+ ```
279
+
280
+ Frozen bundles, editable installs, and commit-pinned installs are reported
281
+ as non-upgradeable with an explanation and manual next step.
282
+
253
283
  <p align="right">(<a href="#readme-top">back to top</a>)</p>
254
284
 
255
285
  <!-- ENTRY POINTS & PACKAGING -->
@@ -289,9 +319,14 @@ file is always named `usecli.config.toml`):
289
319
  # main.py
290
320
  from usecli import run
291
321
 
292
- if __name__ == "__main__":
322
+
323
+ def main():
293
324
  run() # auto-detect project config
294
325
  # run("path/to/usecli.config.toml") # or point at a specific config
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()
295
330
  ```
296
331
 
297
332
  Run it in development (no PyInstaller required):
@@ -300,6 +335,14 @@ Run it in development (no PyInstaller required):
300
335
  uv run python main.py --help
301
336
  ```
302
337
 
338
+ To expose the same entry point as a console script, point `[project.scripts]`
339
+ at the `main` function in your `myapp` package (the usual `src/myapp` layout):
340
+
341
+ ```toml
342
+ [project.scripts]
343
+ myapp = "myapp:main"
344
+ ```
345
+
303
346
  Because `run()` injects the located config explicitly, a custom `main.py` works
304
347
  regardless of whether your CLI also has a `[project.scripts]` entry.
305
348
 
@@ -215,11 +215,41 @@ 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)
221
222
  ```
222
223
 
224
+ ### Upgrades
225
+
226
+ Every CLI built on usecli ships `upgrade`, which detects how the running
227
+ package was installed and delegates to the same package manager — `uv tool`,
228
+ `pipx`, or `pip` — instead of creating a second installation:
229
+
230
+ ```bash
231
+ mycli upgrade --check # show installed/latest versions and the source
232
+ mycli upgrade # confirm and install the latest release
233
+ mycli upgrade --force # skip the prompt and the up-to-date check
234
+ ```
235
+
236
+ The source comes from packaging metadata: PyPI for index installs, and the
237
+ Git remote for PEP 610 `direct_url.json` installs. Override it in
238
+ `usecli.config.toml` when metadata is not enough (private mirrors, missing
239
+ `direct_url.json`, a package name that differs from the command name):
240
+
241
+ ```toml
242
+ [usecli.upgrade]
243
+ enabled = true
244
+ source = "git" # "auto" (default) | "pypi" | "git"
245
+ package = "mycli" # optional
246
+ url = "https://github.com/owner/mycli.git" # required for "git" unless detected
247
+ ref = "main" # branch, tag, or commit
248
+ ```
249
+
250
+ Frozen bundles, editable installs, and commit-pinned installs are reported
251
+ as non-upgradeable with an explanation and manual next step.
252
+
223
253
  <p align="right">(<a href="#readme-top">back to top</a>)</p>
224
254
 
225
255
  <!-- ENTRY POINTS & PACKAGING -->
@@ -259,9 +289,14 @@ file is always named `usecli.config.toml`):
259
289
  # main.py
260
290
  from usecli import run
261
291
 
262
- if __name__ == "__main__":
292
+
293
+ def main():
263
294
  run() # auto-detect project config
264
295
  # run("path/to/usecli.config.toml") # or point at a specific config
296
+
297
+
298
+ if __name__ == "__main__":
299
+ main()
265
300
  ```
266
301
 
267
302
  Run it in development (no PyInstaller required):
@@ -270,6 +305,14 @@ Run it in development (no PyInstaller required):
270
305
  uv run python main.py --help
271
306
  ```
272
307
 
308
+ To expose the same entry point as a console script, point `[project.scripts]`
309
+ at the `main` function in your `myapp` package (the usual `src/myapp` layout):
310
+
311
+ ```toml
312
+ [project.scripts]
313
+ myapp = "myapp:main"
314
+ ```
315
+
273
316
  Because `run()` injects the located config explicitly, a custom `main.py` works
274
317
  regardless of whether your CLI also has a `[project.scripts]` entry.
275
318
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usecli"
3
- version = "0.1.78"
3
+ version = "0.1.80"
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.80"
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,13 @@ 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 }}
15
+ # Where upgrades come from: "auto" (detect from install metadata), "pypi", or "git".
16
+ source = "{{ upgrade_source | default('auto') }}"
17
+ # Git only: repository URL. Falls back to the URL in the install metadata.
18
+ # url = "https://github.com/owner/repo.git"
19
+ # Optional: distribution name and Git ref (branch, tag, or commit) overrides.
20
+ # package = "my-cli"
21
+ # ref = "main"
@@ -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,16 @@ class ConfigManager:
284
284
  "environment": "prod",
285
285
  "command_name": "usecli",
286
286
  "hide_inspire": False,
287
+ # The built-in `upgrade` command reads [usecli.upgrade]. `source`
288
+ # overrides install-source detection: "auto" (packaging metadata),
289
+ # "pypi", or "git" (with `package`/`url`/`ref` overrides).
290
+ "upgrade": {
291
+ "enabled": True,
292
+ "source": "auto",
293
+ "package": None,
294
+ "url": None,
295
+ "ref": None,
296
+ },
287
297
  }
288
298
 
289
299
  def __init__(
@@ -320,7 +330,8 @@ class ConfigManager:
320
330
  # find_project_root discovery (up-walk + bounded rglob tree search) that
321
331
  # would otherwise be spent rediscovering it. Derive the project root
322
332
  # from the config's own directory instead.
323
- if usecli_config_path is None:
333
+ # Unreachable: the normalization above guarantees a Path here.
334
+ if usecli_config_path is None: # pragma: no cover
324
335
  detected_root = find_project_root(start_dir)
325
336
  else:
326
337
  detected_root = None
@@ -328,7 +339,7 @@ class ConfigManager:
328
339
  config_parent = self.usecli_config_path.parent
329
340
  if detected_root is None:
330
341
  detected_root = config_parent
331
- else:
342
+ else: # pragma: no cover - detected_root is always None here
332
343
  root_config = detected_root / USECLI_CONFIG_TOML
333
344
  if self.usecli_config_path.resolve() != root_config.resolve():
334
345
  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
+ ]