click-agentcli 0.3.0__tar.gz → 0.5.0__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 (23) hide show
  1. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/PKG-INFO +42 -10
  2. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/README.md +41 -9
  3. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/pyproject.toml +1 -1
  4. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/__init__.py +3 -3
  5. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/candidates.py +19 -16
  6. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/candidates_test.py +59 -4
  7. click_agentcli-0.5.0/src/agentcli/group.py +144 -0
  8. click_agentcli-0.5.0/src/agentcli/group_test.py +231 -0
  9. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/skill.py +141 -61
  10. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/skill_test.py +128 -26
  11. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/uv.lock +1 -1
  12. click_agentcli-0.3.0/src/agentcli/group.py +0 -80
  13. click_agentcli-0.3.0/src/agentcli/group_test.py +0 -87
  14. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/.github/workflows/ci.yml +0 -0
  15. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/.github/workflows/release.yml +0 -0
  16. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/.gitignore +0 -0
  17. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/LICENSE +0 -0
  18. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/exits.py +0 -0
  19. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/exits_test.py +0 -0
  20. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/guide.py +0 -0
  21. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/guide_test.py +0 -0
  22. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/output.py +0 -0
  23. {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/output_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: click-agentcli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Shared CLI conventions for agent-facing tools: exit codes, JSON output, skill installation, and the in-binary guide.
5
5
  Project-URL: Homepage, https://github.com/owahltinez/click-agentcli
6
6
  Author: owahltinez
@@ -118,6 +118,29 @@ Add `@json_option` to each command that supports structured output and call
118
118
  `emit` once with both the data and its human renderer. `JsonAwareGroup` then
119
119
  keeps parse failures structured when `--json` was requested.
120
120
 
121
+ ## Upgrading
122
+
123
+ Upgrading the package is the whole workflow:
124
+
125
+ ```sh
126
+ uv tool upgrade --all
127
+ ```
128
+
129
+ `JsonAwareGroup` rewrites any installed copy of the skill that differs from
130
+ the packaged one, on the way into a command. It only ever rewrites a
131
+ directory that already holds this skill: putting one somewhere new is
132
+ `skill install`. Failures are silent, and `skill` itself is skipped so
133
+ `skill status` still reports drift.
134
+
135
+ Set `AGENTCLI_NO_SKILL_REFRESH=1` to switch it off. A top-level group that
136
+ is not `JsonAwareGroup` asks for the refresh itself:
137
+
138
+ ```python
139
+ from agentcli import refresh_skill
140
+
141
+ refresh_skill(name="acme", package="acme")
142
+ ```
143
+
121
144
  ## Develop this package
122
145
 
123
146
  ```sh
@@ -160,11 +183,16 @@ The stable public surface is:
160
183
 
161
184
  - `UsageError`, `RemoteError`, `AssertionFailure`, and `StrictFailure`.
162
185
  - `dumps`, `emit`, `emit_error`, `json_option`, and `limit_option`.
163
- - `JsonAwareGroup` for every consuming tool's top-level group.
186
+ - `JsonAwareGroup` for every consuming tool's top-level group. It also keeps
187
+ the installed skill in step with the packaged one.
188
+ - `refresh_skill(name=..., package=...)` to do that from another group.
164
189
  - `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
165
190
  `status`. Installation refuses an unrelated destination, recognises owned
166
- broken symlinks, copies by default, and supports `--link`, `--to`, and
167
- `--dry-run`. With no options it installs everywhere the skill is wanted
191
+ broken symlinks an older version left, always copies, and supports `--to`
192
+ and `--dry-run`. `status` compares each installed copy against the packaged
193
+ one and reports `current` or `stale`. It never links: a link points into the environment, whose
194
+ path carries the interpreter version, so a rebuild elsewhere leaves the
195
+ skill silently absent rather than merely stale. With no options it installs everywhere the skill is wanted
168
196
  and refreshes its own earlier copies, so plain `install` is the whole
169
197
  job; a directory holding somebody else's skill is still refused.
170
198
  - `guide_command(text)` for a complete manual available without a network.
@@ -174,7 +202,7 @@ The stable public surface is:
174
202
  ## Candidate contract
175
203
 
176
204
  Candidate sources answer the same question: filter things someone could eat by
177
- per-serving macros, then rank them with provenance. Recipes and restaurant
205
+ per-serving figures, then rank them with provenance. Recipes and restaurant
178
206
  meals therefore emit the same record:
179
207
 
180
208
  ```json
@@ -196,11 +224,15 @@ Sources accept `macro_options` (`--max-kcal`, `--min-protein`) and use `rank`.
196
224
  The rank key is unrounded protein per 100 kcal, then absolute protein, then
197
225
  name. `--max-kcal 0` is valid because zero-calorie records exist.
198
226
 
199
- `per_serving` contains only macros actually known by the source. Missing is
200
- never filled with zero. `complete` exposes whether the full shape is present;
201
- a candidate missing a requested filter macro is excluded and returned in the
202
- source's `unverifiable` or equivalent bucket. Every source emits that bucket,
203
- even when its loader makes it structurally empty.
227
+ `per_serving` contains only figures actually known by the source, whatever
228
+ they are a source that also publishes fibre or sodium puts them here.
229
+ Missing is never filled with zero. `candidate` takes the keys that count as a
230
+ full set from its caller as `required`, because this package does not know what
231
+ a macro is; callers answering the same question share that tuple so `complete`
232
+ keeps meaning the same thing across them. A candidate missing a requested
233
+ filter figure is excluded and returned in the source's `unverifiable` or
234
+ equivalent bucket. Every source emits that bucket, even when its loader makes
235
+ it structurally empty.
204
236
 
205
237
  This contract is the reason the tools can be independent packages: an
206
238
  orchestrator can merge and rank results without knowing which source answered.
@@ -94,6 +94,29 @@ Add `@json_option` to each command that supports structured output and call
94
94
  `emit` once with both the data and its human renderer. `JsonAwareGroup` then
95
95
  keeps parse failures structured when `--json` was requested.
96
96
 
97
+ ## Upgrading
98
+
99
+ Upgrading the package is the whole workflow:
100
+
101
+ ```sh
102
+ uv tool upgrade --all
103
+ ```
104
+
105
+ `JsonAwareGroup` rewrites any installed copy of the skill that differs from
106
+ the packaged one, on the way into a command. It only ever rewrites a
107
+ directory that already holds this skill: putting one somewhere new is
108
+ `skill install`. Failures are silent, and `skill` itself is skipped so
109
+ `skill status` still reports drift.
110
+
111
+ Set `AGENTCLI_NO_SKILL_REFRESH=1` to switch it off. A top-level group that
112
+ is not `JsonAwareGroup` asks for the refresh itself:
113
+
114
+ ```python
115
+ from agentcli import refresh_skill
116
+
117
+ refresh_skill(name="acme", package="acme")
118
+ ```
119
+
97
120
  ## Develop this package
98
121
 
99
122
  ```sh
@@ -136,11 +159,16 @@ The stable public surface is:
136
159
 
137
160
  - `UsageError`, `RemoteError`, `AssertionFailure`, and `StrictFailure`.
138
161
  - `dumps`, `emit`, `emit_error`, `json_option`, and `limit_option`.
139
- - `JsonAwareGroup` for every consuming tool's top-level group.
162
+ - `JsonAwareGroup` for every consuming tool's top-level group. It also keeps
163
+ the installed skill in step with the packaged one.
164
+ - `refresh_skill(name=..., package=...)` to do that from another group.
140
165
  - `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
141
166
  `status`. Installation refuses an unrelated destination, recognises owned
142
- broken symlinks, copies by default, and supports `--link`, `--to`, and
143
- `--dry-run`. With no options it installs everywhere the skill is wanted
167
+ broken symlinks an older version left, always copies, and supports `--to`
168
+ and `--dry-run`. `status` compares each installed copy against the packaged
169
+ one and reports `current` or `stale`. It never links: a link points into the environment, whose
170
+ path carries the interpreter version, so a rebuild elsewhere leaves the
171
+ skill silently absent rather than merely stale. With no options it installs everywhere the skill is wanted
144
172
  and refreshes its own earlier copies, so plain `install` is the whole
145
173
  job; a directory holding somebody else's skill is still refused.
146
174
  - `guide_command(text)` for a complete manual available without a network.
@@ -150,7 +178,7 @@ The stable public surface is:
150
178
  ## Candidate contract
151
179
 
152
180
  Candidate sources answer the same question: filter things someone could eat by
153
- per-serving macros, then rank them with provenance. Recipes and restaurant
181
+ per-serving figures, then rank them with provenance. Recipes and restaurant
154
182
  meals therefore emit the same record:
155
183
 
156
184
  ```json
@@ -172,11 +200,15 @@ Sources accept `macro_options` (`--max-kcal`, `--min-protein`) and use `rank`.
172
200
  The rank key is unrounded protein per 100 kcal, then absolute protein, then
173
201
  name. `--max-kcal 0` is valid because zero-calorie records exist.
174
202
 
175
- `per_serving` contains only macros actually known by the source. Missing is
176
- never filled with zero. `complete` exposes whether the full shape is present;
177
- a candidate missing a requested filter macro is excluded and returned in the
178
- source's `unverifiable` or equivalent bucket. Every source emits that bucket,
179
- even when its loader makes it structurally empty.
203
+ `per_serving` contains only figures actually known by the source, whatever
204
+ they are a source that also publishes fibre or sodium puts them here.
205
+ Missing is never filled with zero. `candidate` takes the keys that count as a
206
+ full set from its caller as `required`, because this package does not know what
207
+ a macro is; callers answering the same question share that tuple so `complete`
208
+ keeps meaning the same thing across them. A candidate missing a requested
209
+ filter figure is excluded and returned in the source's `unverifiable` or
210
+ equivalent bucket. Every source emits that bucket, even when its loader makes
211
+ it structurally empty.
180
212
 
181
213
  This contract is the reason the tools can be independent packages: an
182
214
  orchestrator can merge and rank results without knowing which source answered.
@@ -10,7 +10,7 @@ build-backend = "hatchling.build"
10
10
  # Dependents must name the distribution, not the import, in both their
11
11
  # dependencies and their [tool.uv.sources] key.
12
12
  name = "click-agentcli"
13
- version = "0.3.0"
13
+ version = "0.5.0"
14
14
  description = "Shared CLI conventions for agent-facing tools: exit codes, JSON output, skill installation, and the in-binary guide."
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.12"
@@ -2,7 +2,6 @@
2
2
 
3
3
  from agentcli.candidates import (
4
4
  KINDS,
5
- MACRO_KEYS,
6
5
  candidate,
7
6
  macro_options,
8
7
  matches,
@@ -18,14 +17,14 @@ from agentcli.exits import (
18
17
  from agentcli.group import JsonAwareGroup
19
18
  from agentcli.guide import guide_command
20
19
  from agentcli.output import dumps, emit, emit_error, json_option, limit_option
21
- from agentcli.skill import skill_group
20
+ from agentcli.skill import SkillGroup, refresh_skill, skill_group
22
21
 
23
22
  __all__ = [
24
23
  "KINDS",
25
- "MACRO_KEYS",
26
24
  "AssertionFailure",
27
25
  "JsonAwareGroup",
28
26
  "RemoteError",
27
+ "SkillGroup",
29
28
  "StrictFailure",
30
29
  "UsageError",
31
30
  "candidate",
@@ -38,6 +37,7 @@ __all__ = [
38
37
  "macro_options",
39
38
  "matches",
40
39
  "rank",
40
+ "refresh_skill",
41
41
  "skill_group",
42
42
  "unverifiable",
43
43
  ]
@@ -16,8 +16,6 @@ from typing import Any
16
16
 
17
17
  import click
18
18
 
19
- MACRO_KEYS = ("kcal", "protein", "fat", "carbs")
20
-
21
19
  KINDS = ("recipe", "meal")
22
20
 
23
21
 
@@ -27,29 +25,34 @@ def candidate(
27
25
  identifier: str,
28
26
  name: str,
29
27
  per_serving: dict[str, float | None],
28
+ required: tuple[str, ...],
30
29
  detail: dict[str, Any] | None = None,
31
30
  ) -> dict[str, Any]:
32
31
  """One comparable option, per serving.
33
32
 
34
- `per_serving` carries every macro the source published and omits the rest.
35
- A macro is never defaulted to zero to fill the shape: a dish whose fat was
36
- never measured is not a fat-free dish, and `complete` is what tells them
37
- apart.
33
+ `per_serving` carries every figure the source published, whatever they are,
34
+ and omits the rest. A figure is never defaulted to zero to fill the shape:
35
+ a dish whose fat was never measured is not a fat-free dish, and `complete`
36
+ is what tells them apart.
37
+
38
+ `required` is the caller's -- which figures it considers a full set. This
39
+ module does not know what a macro is, and a source that publishes fibre or
40
+ sodium should be able to say so without asking permission here. Callers
41
+ that answer the same question share the tuple so `complete` keeps meaning
42
+ the same thing across them.
38
43
  """
39
44
  if kind not in KINDS:
40
45
  raise ValueError(f"unknown candidate kind: {kind}")
41
46
 
42
- macros = {
43
- key: per_serving[key]
44
- for key in MACRO_KEYS
45
- if per_serving.get(key) is not None
47
+ published = {
48
+ key: value for key, value in per_serving.items() if value is not None
46
49
  }
47
50
  return {
48
51
  "kind": kind,
49
52
  "id": identifier,
50
53
  "name": name,
51
- "per_serving": macros,
52
- "complete": len(macros) == len(MACRO_KEYS),
54
+ "per_serving": published,
55
+ "complete": all(key in published for key in required),
53
56
  "detail": detail or {},
54
57
  }
55
58
 
@@ -76,15 +79,15 @@ def matches(
76
79
  ) -> bool:
77
80
  """Whether a candidate provably satisfies the constraints.
78
81
 
79
- A candidate missing the macro a filter asks about is excluded, because it
82
+ A candidate missing the figure a filter asks about is excluded, because it
80
83
  cannot be shown to pass. Callers report those separately rather than
81
84
  dropping them silently — "no results" and "three results I could not check"
82
85
  are different answers.
83
86
  """
84
- macros = record["per_serving"]
85
- kcal, protein = macros.get("kcal"), macros.get("protein")
87
+ published = record["per_serving"]
88
+ kcal, protein = published.get("kcal"), published.get("protein")
86
89
 
87
- # A missing macro fails the filter that asks about it rather than being
90
+ # A missing figure fails the filter that asks about it rather than being
88
91
  # treated as zero, which would pass every ceiling and fail every floor.
89
92
  over = max_kcal is not None and (kcal is None or kcal > max_kcal)
90
93
  under = min_protein is not None and (
@@ -17,6 +17,10 @@ from agentcli.candidates import (
17
17
  FULL = {"kcal": 384.2, "protein": 31.5, "fat": 12.1, "carbs": 38.4}
18
18
  PARTIAL = {"kcal": 540.0, "protein": 45.8, "fat": None, "carbs": None}
19
19
 
20
+ # What the nutrition tools happen to require. This module does not define it --
21
+ # it is a caller's tuple, and these tests stand in for a caller.
22
+ REQUIRED = ("kcal", "protein", "fat", "carbs")
23
+
20
24
 
21
25
  def make(kind: str = "recipe", name: str = "Thing", **macros: object) -> dict:
22
26
  return candidate(
@@ -24,13 +28,18 @@ def make(kind: str = "recipe", name: str = "Thing", **macros: object) -> dict:
24
28
  identifier=name.lower(),
25
29
  name=name,
26
30
  per_serving=macros or FULL,
31
+ required=REQUIRED,
27
32
  )
28
33
 
29
34
 
30
35
  def test_an_unpublished_macro_is_omitted_rather_than_zeroed() -> None:
31
36
  """A dish whose fat was never measured is not a fat-free dish."""
32
37
  partial = candidate(
33
- kind="meal", identifier="x", name="Bowl", per_serving=PARTIAL
38
+ kind="meal",
39
+ identifier="x",
40
+ name="Bowl",
41
+ per_serving=PARTIAL,
42
+ required=REQUIRED,
34
43
  )
35
44
 
36
45
  assert partial["per_serving"] == {"kcal": 540.0, "protein": 45.8}
@@ -38,11 +47,44 @@ def test_an_unpublished_macro_is_omitted_rather_than_zeroed() -> None:
38
47
  assert partial["complete"] is False
39
48
 
40
49
 
41
- def test_complete_means_all_four_macros_present() -> None:
50
+ def test_complete_means_every_figure_the_caller_required() -> None:
42
51
  assert make()["complete"] is True
43
52
  assert make(kcal=1.0, protein=1.0, fat=1.0)["complete"] is False
44
53
 
45
54
 
55
+ def test_a_figure_the_caller_did_not_require_is_still_carried() -> None:
56
+ """A source publishing fibre should not have to ask permission here.
57
+
58
+ Filtering to a fixed set of keys meant a tool that had resolved a fibre
59
+ figure could not publish it, and an agent following the documented rule
60
+ -- look for it in `per_serving` -- concluded it was unavailable.
61
+ """
62
+ record = candidate(
63
+ kind="recipe",
64
+ identifier="soup",
65
+ name="Soup",
66
+ per_serving={**FULL, "dietary_fiber": 4.1, "sodium": 0.4},
67
+ required=REQUIRED,
68
+ )
69
+
70
+ assert record["per_serving"]["dietary_fiber"] == 4.1
71
+ assert record["per_serving"]["sodium"] == 0.4
72
+ assert record["complete"] is True
73
+
74
+
75
+ def test_a_caller_requiring_less_is_complete_with_less() -> None:
76
+ """Two tools answer different questions; neither defines the other's set."""
77
+ record = candidate(
78
+ kind="meal",
79
+ identifier="x",
80
+ name="X",
81
+ per_serving={"kcal": 540.0, "protein": 45.8},
82
+ required=("kcal", "protein"),
83
+ )
84
+
85
+ assert record["complete"] is True
86
+
87
+
46
88
  def test_detail_is_where_the_kinds_differ() -> None:
47
89
  """Nothing shared reads `detail`, so the kinds cannot collide in it."""
48
90
  meal = candidate(
@@ -50,6 +92,7 @@ def test_detail_is_where_the_kinds_differ() -> None:
50
92
  identifier="crust-margherita",
51
93
  name="Margherita",
52
94
  per_serving=FULL,
95
+ required=REQUIRED,
53
96
  detail={"restaurant": "Crust Pizza", "distance_km": 1.5},
54
97
  )
55
98
 
@@ -67,7 +110,13 @@ def test_detail_is_where_the_kinds_differ() -> None:
67
110
  def test_an_unknown_kind_is_refused() -> None:
68
111
  """A third kind is a decision, not something a caller slips in."""
69
112
  with pytest.raises(ValueError, match="unknown candidate kind"):
70
- candidate(kind="snack", identifier="x", name="X", per_serving=FULL)
113
+ candidate(
114
+ kind="snack",
115
+ identifier="x",
116
+ name="X",
117
+ per_serving=FULL,
118
+ required=REQUIRED,
119
+ )
71
120
 
72
121
 
73
122
  @pytest.mark.parametrize(
@@ -102,7 +151,11 @@ def test_a_missing_macro_fails_the_filter_that_asks_about_it() -> None:
102
151
 
103
152
  def test_no_filters_matches_everything_including_incomplete() -> None:
104
153
  partial = candidate(
105
- kind="meal", identifier="x", name="Bowl", per_serving=PARTIAL
154
+ kind="meal",
155
+ identifier="x",
156
+ name="Bowl",
157
+ per_serving=PARTIAL,
158
+ required=REQUIRED,
106
159
  )
107
160
 
108
161
  assert matches(partial) is True
@@ -170,12 +223,14 @@ def test_a_published_zero_is_not_a_missing_measurement() -> None:
170
223
  identifier="coffee",
171
224
  name="Black Coffee",
172
225
  per_serving={"kcal": 0.0, "protein": 0.0, "fat": 0.0, "carbs": 0.0},
226
+ required=REQUIRED,
173
227
  )
174
228
  unmeasured = candidate(
175
229
  kind="meal",
176
230
  identifier="mystery",
177
231
  name="Mystery",
178
232
  per_serving={"protein": 0.0, "fat": 0.0, "carbs": 0.0},
233
+ required=REQUIRED,
179
234
  )
180
235
 
181
236
  assert measured["per_serving"]["kcal"] == 0.0
@@ -0,0 +1,144 @@
1
+ """A command group that keeps the `--json` promise even when it fails.
2
+
3
+ Requesting JSON is a promise that stdout is parseable, and that has to hold for
4
+ failures too. The hard case is a bad flag: click raises before any subcommand
5
+ has parsed `--json`, so at that point nothing in the parsed context knows JSON
6
+ was wanted. The raw argument list is the only place it is known that early.
7
+
8
+ This lives here rather than in each tool because every tool has the same
9
+ problem, and a tool that solves it locally is a tool the next one forgets to
10
+ copy.
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ from collections.abc import Sequence
16
+ from typing import Any, Literal, NoReturn, overload
17
+
18
+ import click
19
+
20
+ from agentcli.output import emit_error
21
+ from agentcli.skill import SkillGroup
22
+
23
+ # Set to any non-empty value to keep a run from touching the skills on disk.
24
+ NO_REFRESH_ENV = "AGENTCLI_NO_SKILL_REFRESH"
25
+
26
+
27
+ class JsonAwareGroup(click.Group):
28
+ """Routes `click.ClickException` to the JSON error shape when asked."""
29
+
30
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
31
+ super().__init__(*args, **kwargs)
32
+ self._json_requested = False
33
+
34
+ # The overloads mirror `click.Group.main` exactly, so a caller holding a
35
+ # `click.Group` can call this the same way, positionals included.
36
+ @overload
37
+ def main(
38
+ self,
39
+ args: Sequence[str] | None = None,
40
+ prog_name: str | None = None,
41
+ complete_var: str | None = None,
42
+ standalone_mode: Literal[True] = True,
43
+ **extra: Any,
44
+ ) -> NoReturn: ...
45
+
46
+ @overload
47
+ def main(
48
+ self,
49
+ args: Sequence[str] | None = None,
50
+ prog_name: str | None = None,
51
+ complete_var: str | None = None,
52
+ standalone_mode: bool = ...,
53
+ **extra: Any,
54
+ ) -> Any: ...
55
+
56
+ def main(
57
+ self,
58
+ args: Sequence[str] | None = None,
59
+ prog_name: str | None = None,
60
+ complete_var: str | None = None,
61
+ standalone_mode: bool = True,
62
+ **extra: Any,
63
+ ) -> Any:
64
+ arguments = list(sys.argv[1:] if args is None else args)
65
+ self._json_requested = "--json" in arguments
66
+
67
+ # Only when this process *is* the tool's command line, which is what
68
+ # reading `sys.argv` means. A caller passing its own arguments -- an
69
+ # embedding, or a consumer's `CliRunner` test -- gets no writes under
70
+ # `~` it never asked for.
71
+ if args is None:
72
+ self._refresh_skill(arguments)
73
+
74
+ return super().main(
75
+ arguments,
76
+ prog_name,
77
+ complete_var,
78
+ standalone_mode,
79
+ **extra,
80
+ )
81
+
82
+ def _refresh_skill(self, arguments: Sequence[str]) -> None:
83
+ """Bring already-installed copies of this tool's skill up to date.
84
+
85
+ Upgrading a package never touched a skill already on disk, so the copy
86
+ an agent read could sit releases behind the binary it documents, and
87
+ nothing said so. Doing it here makes `uv tool upgrade` the whole
88
+ workflow: no second command per tool to remember.
89
+
90
+ Not done for `skill` itself. That group is where drift is reported and
91
+ acted on, and a `skill status` that silently repaired what it was
92
+ about to describe would have nothing left to report.
93
+ """
94
+ if os.environ.get(NO_REFRESH_ENV):
95
+ return
96
+
97
+ if "skill" in arguments:
98
+ return
99
+
100
+ skill = self.commands.get("skill")
101
+ if isinstance(skill, SkillGroup):
102
+ skill.refresh()
103
+
104
+ def _drift_hint(self, ctx: click.Context, exc: Exception) -> str:
105
+ """Why a name this caller expected might not exist.
106
+
107
+ A caller reading a skill older than the binary asks for a command the
108
+ binary has since renamed or dropped, and no other failure looks like
109
+ this. The hint lives here rather than in the skill because the skill
110
+ is the thing that went stale, while the binary is what was upgraded.
111
+ """
112
+ if not isinstance(exc, click.NoSuchOption | click.UsageError):
113
+ return ""
114
+ if "No such command" not in str(exc) and not isinstance(
115
+ exc, click.NoSuchOption
116
+ ):
117
+ return ""
118
+ if "skill" not in self.commands:
119
+ return ""
120
+ tool = ctx.find_root().info_name or "this tool"
121
+ return (
122
+ f" If this was documented, the installed skill predates this "
123
+ f"version; run `{tool} skill install`."
124
+ )
125
+
126
+ def invoke(self, ctx: click.Context) -> Any:
127
+ try:
128
+ return super().invoke(ctx)
129
+ except click.ClickException as exc:
130
+ hint = self._drift_hint(ctx, exc)
131
+ # The human path stays click's own, which prints the usage block
132
+ # too: worth more to a person than a uniform shape.
133
+ if not self._json_requested:
134
+ if hint:
135
+ # Re-raised rather than edited: click marks the message
136
+ # final, and a UsageError still prints usage and exits 2.
137
+ raise click.UsageError(
138
+ f"{exc.format_message()}{hint}",
139
+ ctx=getattr(exc, "ctx", None),
140
+ ) from exc
141
+ raise
142
+
143
+ emit_error(f"{exc.format_message()}{hint}", json_output=True)
144
+ ctx.exit(exc.exit_code)