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.
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/PKG-INFO +42 -10
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/README.md +41 -9
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/pyproject.toml +1 -1
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/__init__.py +3 -3
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/candidates.py +19 -16
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/candidates_test.py +59 -4
- click_agentcli-0.5.0/src/agentcli/group.py +144 -0
- click_agentcli-0.5.0/src/agentcli/group_test.py +231 -0
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/skill.py +141 -61
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/skill_test.py +128 -26
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/uv.lock +1 -1
- click_agentcli-0.3.0/src/agentcli/group.py +0 -80
- click_agentcli-0.3.0/src/agentcli/group_test.py +0 -87
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/.github/workflows/ci.yml +0 -0
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/.github/workflows/release.yml +0 -0
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/.gitignore +0 -0
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/LICENSE +0 -0
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/exits.py +0 -0
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/exits_test.py +0 -0
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/guide.py +0 -0
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/guide_test.py +0 -0
- {click_agentcli-0.3.0 → click_agentcli-0.5.0}/src/agentcli/output.py +0 -0
- {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
|
+
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
|
|
167
|
-
`--dry-run`.
|
|
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
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
143
|
-
`--dry-run`.
|
|
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
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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.
|
|
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
|
|
35
|
-
A
|
|
36
|
-
never measured is not a fat-free dish, and `complete`
|
|
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
|
-
|
|
43
|
-
key: per_serving
|
|
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":
|
|
52
|
-
"complete":
|
|
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
|
|
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
|
-
|
|
85
|
-
kcal, protein =
|
|
87
|
+
published = record["per_serving"]
|
|
88
|
+
kcal, protein = published.get("kcal"), published.get("protein")
|
|
86
89
|
|
|
87
|
-
# A missing
|
|
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",
|
|
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
|
|
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(
|
|
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",
|
|
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)
|