click-agentcli 0.1.0__py3-none-any.whl
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.
- agentcli/__init__.py +43 -0
- agentcli/candidates.py +141 -0
- agentcli/candidates_test.py +195 -0
- agentcli/exits.py +48 -0
- agentcli/exits_test.py +66 -0
- agentcli/group.py +82 -0
- agentcli/group_test.py +89 -0
- agentcli/guide.py +21 -0
- agentcli/guide_test.py +14 -0
- agentcli/output.py +85 -0
- agentcli/output_test.py +91 -0
- agentcli/skill.py +375 -0
- agentcli/skill_test.py +385 -0
- click_agentcli-0.1.0.dist-info/METADATA +116 -0
- click_agentcli-0.1.0.dist-info/RECORD +17 -0
- click_agentcli-0.1.0.dist-info/WHEEL +4 -0
- click_agentcli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentcli/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared conventions for agent-facing CLI tools."""
|
|
2
|
+
|
|
3
|
+
from agentcli.candidates import (
|
|
4
|
+
KINDS,
|
|
5
|
+
MACRO_KEYS,
|
|
6
|
+
candidate,
|
|
7
|
+
macro_options,
|
|
8
|
+
matches,
|
|
9
|
+
rank,
|
|
10
|
+
unverifiable,
|
|
11
|
+
)
|
|
12
|
+
from agentcli.exits import (
|
|
13
|
+
AssertionFailure,
|
|
14
|
+
RemoteError,
|
|
15
|
+
StrictFailure,
|
|
16
|
+
UsageError,
|
|
17
|
+
)
|
|
18
|
+
from agentcli.group import JsonAwareGroup
|
|
19
|
+
from agentcli.guide import guide_command
|
|
20
|
+
from agentcli.output import dumps, emit, emit_error, json_option, limit_option
|
|
21
|
+
from agentcli.skill import skill_group
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"KINDS",
|
|
25
|
+
"MACRO_KEYS",
|
|
26
|
+
"AssertionFailure",
|
|
27
|
+
"JsonAwareGroup",
|
|
28
|
+
"RemoteError",
|
|
29
|
+
"StrictFailure",
|
|
30
|
+
"UsageError",
|
|
31
|
+
"candidate",
|
|
32
|
+
"dumps",
|
|
33
|
+
"emit",
|
|
34
|
+
"emit_error",
|
|
35
|
+
"guide_command",
|
|
36
|
+
"json_option",
|
|
37
|
+
"limit_option",
|
|
38
|
+
"macro_options",
|
|
39
|
+
"matches",
|
|
40
|
+
"rank",
|
|
41
|
+
"skill_group",
|
|
42
|
+
"unverifiable",
|
|
43
|
+
]
|
agentcli/candidates.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""A thing you could eat, and the macros you would decide on.
|
|
2
|
+
|
|
3
|
+
Two questions have the same shape — "what can I cook under 400 kcal a serving
|
|
4
|
+
with 30 g of protein" and "where can I eat out under 800 kcal with 35 g" — so
|
|
5
|
+
the tools that answer them emit the same record. A recipe and a restaurant dish
|
|
6
|
+
differ in how they came to exist and in what detail they can show, not in what
|
|
7
|
+
a decision needs from them.
|
|
8
|
+
|
|
9
|
+
That shared part lives here so an orchestrator merges two JSON streams and
|
|
10
|
+
ranks, instead of special-casing each tool. Everything kind-specific goes under
|
|
11
|
+
`detail`, which nothing shared ever reads.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
MACRO_KEYS = ("kcal", "protein", "fat", "carbs")
|
|
22
|
+
|
|
23
|
+
KINDS = ("recipe", "meal")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def candidate(
|
|
27
|
+
*,
|
|
28
|
+
kind: str,
|
|
29
|
+
identifier: str,
|
|
30
|
+
name: str,
|
|
31
|
+
per_serving: dict[str, float | None],
|
|
32
|
+
detail: dict[str, Any] | None = None,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""One comparable option, per serving.
|
|
35
|
+
|
|
36
|
+
`per_serving` carries every macro the source published and omits the rest.
|
|
37
|
+
A macro is never defaulted to zero to fill the shape: a dish whose fat was
|
|
38
|
+
never measured is not a fat-free dish, and `complete` is what tells them
|
|
39
|
+
apart.
|
|
40
|
+
"""
|
|
41
|
+
if kind not in KINDS:
|
|
42
|
+
raise ValueError(f"unknown candidate kind: {kind}")
|
|
43
|
+
|
|
44
|
+
macros = {
|
|
45
|
+
key: per_serving[key]
|
|
46
|
+
for key in MACRO_KEYS
|
|
47
|
+
if per_serving.get(key) is not None
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
"kind": kind,
|
|
51
|
+
"id": identifier,
|
|
52
|
+
"name": name,
|
|
53
|
+
"per_serving": macros,
|
|
54
|
+
"complete": len(macros) == len(MACRO_KEYS),
|
|
55
|
+
"detail": detail or {},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def macro_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
60
|
+
"""The two filters every candidate source accepts, spelled identically."""
|
|
61
|
+
f = click.option(
|
|
62
|
+
"--min-protein",
|
|
63
|
+
type=click.FloatRange(min=0),
|
|
64
|
+
help="Least protein, in grams per serving.",
|
|
65
|
+
)(f)
|
|
66
|
+
return click.option(
|
|
67
|
+
"--max-kcal",
|
|
68
|
+
type=click.FloatRange(min=0),
|
|
69
|
+
help="Most energy, in kcal per serving.",
|
|
70
|
+
)(f)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def matches(
|
|
74
|
+
record: dict[str, Any],
|
|
75
|
+
*,
|
|
76
|
+
max_kcal: float | None = None,
|
|
77
|
+
min_protein: float | None = None,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Whether a candidate provably satisfies the constraints.
|
|
80
|
+
|
|
81
|
+
A candidate missing the macro a filter asks about is excluded, because it
|
|
82
|
+
cannot be shown to pass. Callers report those separately rather than
|
|
83
|
+
dropping them silently — "no results" and "three results I could not check"
|
|
84
|
+
are different answers.
|
|
85
|
+
"""
|
|
86
|
+
macros = record["per_serving"]
|
|
87
|
+
kcal, protein = macros.get("kcal"), macros.get("protein")
|
|
88
|
+
|
|
89
|
+
# A missing macro fails the filter that asks about it rather than being
|
|
90
|
+
# treated as zero, which would pass every ceiling and fail every floor.
|
|
91
|
+
over = max_kcal is not None and (kcal is None or kcal > max_kcal)
|
|
92
|
+
under = min_protein is not None and (
|
|
93
|
+
protein is None or protein < min_protein
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return not (over or under)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def unverifiable(
|
|
100
|
+
record: dict[str, Any],
|
|
101
|
+
*,
|
|
102
|
+
max_kcal: float | None = None,
|
|
103
|
+
min_protein: float | None = None,
|
|
104
|
+
) -> bool:
|
|
105
|
+
"""Whether a filter was asked about a macro this candidate lacks."""
|
|
106
|
+
macros = record["per_serving"]
|
|
107
|
+
return (max_kcal is not None and macros.get("kcal") is None) or (
|
|
108
|
+
min_protein is not None and macros.get("protein") is None
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def rank(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
113
|
+
"""Protein per 100 kcal, descending. Ties break by name, not file order.
|
|
114
|
+
|
|
115
|
+
One ranking for both kinds, so a merged list is ordered the same way
|
|
116
|
+
whoever produced it. Deterministic on every machine: no locale collation.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def key(record: dict[str, Any]) -> tuple[float, float, str]:
|
|
120
|
+
macros = record["per_serving"]
|
|
121
|
+
|
|
122
|
+
# `or 0.0` would be wrong here, and wrong in the one file that defines
|
|
123
|
+
# the missing-value contract: a published 0 kcal is a fact about black
|
|
124
|
+
# coffee, not an absent measurement. They coincide in the arithmetic
|
|
125
|
+
# below but must not coincide in the idiom.
|
|
126
|
+
kcal = macros.get("kcal")
|
|
127
|
+
protein = macros.get("protein")
|
|
128
|
+
|
|
129
|
+
density = (
|
|
130
|
+
protein / kcal * 100
|
|
131
|
+
if kcal is not None and protein is not None and kcal > 0
|
|
132
|
+
else 0.0
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
-density,
|
|
137
|
+
-(protein if protein is not None else 0.0),
|
|
138
|
+
record["name"],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return sorted(records, key=key)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The contract two independent tools have to agree on without talking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import pytest
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from agentcli.candidates import (
|
|
12
|
+
candidate,
|
|
13
|
+
macro_options,
|
|
14
|
+
matches,
|
|
15
|
+
rank,
|
|
16
|
+
unverifiable,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
FULL = {"kcal": 384.2, "protein": 31.5, "fat": 12.1, "carbs": 38.4}
|
|
20
|
+
PARTIAL = {"kcal": 540.0, "protein": 45.8, "fat": None, "carbs": None}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make(kind: str = "recipe", name: str = "Thing", **macros: object) -> dict:
|
|
24
|
+
return candidate(
|
|
25
|
+
kind=kind,
|
|
26
|
+
identifier=name.lower(),
|
|
27
|
+
name=name,
|
|
28
|
+
per_serving=macros or FULL,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_an_unpublished_macro_is_omitted_rather_than_zeroed() -> None:
|
|
33
|
+
"""A dish whose fat was never measured is not a fat-free dish."""
|
|
34
|
+
partial = candidate(
|
|
35
|
+
kind="meal", identifier="x", name="Bowl", per_serving=PARTIAL
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assert partial["per_serving"] == {"kcal": 540.0, "protein": 45.8}
|
|
39
|
+
assert "fat" not in partial["per_serving"]
|
|
40
|
+
assert partial["complete"] is False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_complete_means_all_four_macros_present() -> None:
|
|
44
|
+
assert make()["complete"] is True
|
|
45
|
+
assert make(kcal=1.0, protein=1.0, fat=1.0)["complete"] is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_detail_is_where_the_kinds_differ() -> None:
|
|
49
|
+
"""Nothing shared reads `detail`, so the kinds cannot collide in it."""
|
|
50
|
+
meal = candidate(
|
|
51
|
+
kind="meal",
|
|
52
|
+
identifier="crust-margherita",
|
|
53
|
+
name="Margherita",
|
|
54
|
+
per_serving=FULL,
|
|
55
|
+
detail={"restaurant": "Crust Pizza", "distance_km": 1.5},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert meal["detail"]["restaurant"] == "Crust Pizza"
|
|
59
|
+
assert set(meal) == {
|
|
60
|
+
"kind",
|
|
61
|
+
"id",
|
|
62
|
+
"name",
|
|
63
|
+
"per_serving",
|
|
64
|
+
"complete",
|
|
65
|
+
"detail",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_an_unknown_kind_is_refused() -> None:
|
|
70
|
+
"""A third kind is a decision, not something a caller slips in."""
|
|
71
|
+
with pytest.raises(ValueError, match="unknown candidate kind"):
|
|
72
|
+
candidate(kind="snack", identifier="x", name="X", per_serving=FULL)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize(
|
|
76
|
+
("max_kcal", "expected"), [(384.2, True), (384.1, False), (500.0, True)]
|
|
77
|
+
)
|
|
78
|
+
def test_the_calorie_ceiling_is_inclusive(
|
|
79
|
+
max_kcal: float, expected: bool
|
|
80
|
+
) -> None:
|
|
81
|
+
assert matches(make(), max_kcal=max_kcal) is expected
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize(
|
|
85
|
+
("min_protein", "expected"), [(31.5, True), (31.6, False), (10.0, True)]
|
|
86
|
+
)
|
|
87
|
+
def test_the_protein_floor_is_inclusive(
|
|
88
|
+
min_protein: float, expected: bool
|
|
89
|
+
) -> None:
|
|
90
|
+
assert matches(make(), min_protein=min_protein) is expected
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_a_missing_macro_fails_the_filter_that_asks_about_it() -> None:
|
|
94
|
+
"""Treating it as zero would pass every ceiling and fail every floor."""
|
|
95
|
+
no_kcal = make(protein=40.0, fat=1.0, carbs=1.0)
|
|
96
|
+
|
|
97
|
+
assert matches(no_kcal, max_kcal=800) is False
|
|
98
|
+
# And it is not silently a miss: the caller can say why it could not check.
|
|
99
|
+
assert unverifiable(no_kcal, max_kcal=800) is True
|
|
100
|
+
# A filter about a macro it does have is answerable as normal.
|
|
101
|
+
assert matches(no_kcal, min_protein=30) is True
|
|
102
|
+
assert unverifiable(no_kcal, min_protein=30) is False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_no_filters_matches_everything_including_incomplete() -> None:
|
|
106
|
+
partial = candidate(
|
|
107
|
+
kind="meal", identifier="x", name="Bowl", per_serving=PARTIAL
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
assert matches(partial) is True
|
|
111
|
+
assert unverifiable(partial) is False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_ranking_is_protein_density_then_name() -> None:
|
|
115
|
+
dense = make(name="Dense", kcal=200.0, protein=30.0, fat=1.0, carbs=1.0)
|
|
116
|
+
lean = make(name="Lean", kcal=600.0, protein=30.0, fat=1.0, carbs=1.0)
|
|
117
|
+
|
|
118
|
+
assert [c["name"] for c in rank([lean, dense])] == ["Dense", "Lean"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_ranking_does_not_depend_on_input_order() -> None:
|
|
122
|
+
"""A merged list must be ordered the same whoever produced it."""
|
|
123
|
+
tied = [
|
|
124
|
+
make(name=name, kcal=400.0, protein=20.0, fat=1.0, carbs=1.0)
|
|
125
|
+
for name in ("Beta", "Alpha", "Gamma")
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
orders = {
|
|
129
|
+
tuple(c["name"] for c in rank(list(p)))
|
|
130
|
+
for p in itertools.permutations(tied)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
assert orders == {("Alpha", "Beta", "Gamma")}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_a_zero_calorie_candidate_does_not_divide_by_zero() -> None:
|
|
137
|
+
"""Water and black coffee are real entries, not arithmetic hazards."""
|
|
138
|
+
zero = make(name="Water", kcal=0.0, protein=0.0, fat=0.0, carbs=0.0)
|
|
139
|
+
|
|
140
|
+
assert [c["name"] for c in rank([zero, make(name="Food")])] == [
|
|
141
|
+
"Food",
|
|
142
|
+
"Water",
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_both_tools_spell_the_filters_identically() -> None:
|
|
147
|
+
"""The flags are shared code precisely so they cannot drift apart."""
|
|
148
|
+
|
|
149
|
+
@click.command()
|
|
150
|
+
@macro_options
|
|
151
|
+
def cli(max_kcal: float | None, min_protein: float | None) -> None:
|
|
152
|
+
click.echo(f"{max_kcal} {min_protein}")
|
|
153
|
+
|
|
154
|
+
runner = CliRunner()
|
|
155
|
+
assert runner.invoke(cli, []).output.strip() == "None None"
|
|
156
|
+
ok = runner.invoke(cli, ["--max-kcal", "400", "--min-protein", "30"])
|
|
157
|
+
assert ok.output.strip() == "400.0 30.0"
|
|
158
|
+
|
|
159
|
+
# A negative ceiling is a mistake, not something to clamp silently.
|
|
160
|
+
assert runner.invoke(cli, ["--max-kcal", "-1"]).exit_code == 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_a_published_zero_is_not_a_missing_measurement() -> None:
|
|
164
|
+
"""The distinction this whole project turns on, in the file that defines it.
|
|
165
|
+
|
|
166
|
+
Black coffee at 0 kcal is a measured fact; a dish whose energy was never
|
|
167
|
+
recorded is not. They happen to rank the same, so only the idiom keeps them
|
|
168
|
+
apart -- which is exactly why it is asserted here rather than assumed.
|
|
169
|
+
"""
|
|
170
|
+
measured = candidate(
|
|
171
|
+
kind="meal",
|
|
172
|
+
identifier="coffee",
|
|
173
|
+
name="Black Coffee",
|
|
174
|
+
per_serving={"kcal": 0.0, "protein": 0.0, "fat": 0.0, "carbs": 0.0},
|
|
175
|
+
)
|
|
176
|
+
unmeasured = candidate(
|
|
177
|
+
kind="meal",
|
|
178
|
+
identifier="mystery",
|
|
179
|
+
name="Mystery",
|
|
180
|
+
per_serving={"protein": 0.0, "fat": 0.0, "carbs": 0.0},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
assert measured["per_serving"]["kcal"] == 0.0
|
|
184
|
+
assert measured["complete"] is True
|
|
185
|
+
|
|
186
|
+
assert "kcal" not in unmeasured["per_serving"]
|
|
187
|
+
assert unmeasured["complete"] is False
|
|
188
|
+
|
|
189
|
+
# A calorie ceiling is answerable for one and not the other.
|
|
190
|
+
assert matches(measured, max_kcal=100) is True
|
|
191
|
+
assert matches(unmeasured, max_kcal=100) is False
|
|
192
|
+
assert unverifiable(unmeasured, max_kcal=100) is True
|
|
193
|
+
|
|
194
|
+
# And neither divides by zero on the way through the shared ranking.
|
|
195
|
+
assert len(rank([measured, unmeasured])) == 2
|
agentcli/exits.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Deterministic exit codes, so an agent never has to read prose.
|
|
2
|
+
|
|
3
|
+
| 0 | success |
|
|
4
|
+
| 1 | usage error: bad flags, unparseable input, refused input |
|
|
5
|
+
| 2 | remote error: network, API, or a site refusal (never retried) |
|
|
6
|
+
| 3 | assertion failure: a caller-stated expectation did not hold |
|
|
7
|
+
| 4 | data-quality warning escalated by `--strict` |
|
|
8
|
+
|
|
9
|
+
`click.ClickException` is the base because click already routes it to stderr
|
|
10
|
+
with a nonzero status; only the code differs per class.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
# Click exits 2 for its own parse failures, which this table documents as a
|
|
18
|
+
# remote error. Agents are told to branch on these codes, and reading a
|
|
19
|
+
# mistyped flag as a transient outage invites a pointless retry, so click is
|
|
20
|
+
# brought into line rather than the documentation bent around it.
|
|
21
|
+
#
|
|
22
|
+
# Applied on import of `agentcli`, which every tool does, because the same
|
|
23
|
+
# correction living in each tool's cli.py is one a new tool silently forgets.
|
|
24
|
+
click.UsageError.exit_code = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UsageError(click.ClickException):
|
|
28
|
+
"""The request could not be understood or was refused as stated."""
|
|
29
|
+
|
|
30
|
+
exit_code = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RemoteError(click.ClickException):
|
|
34
|
+
"""A remote source refused or could not answer. Never retried."""
|
|
35
|
+
|
|
36
|
+
exit_code = 2
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AssertionFailure(click.ClickException):
|
|
40
|
+
"""Something the caller asserted turned out not to hold."""
|
|
41
|
+
|
|
42
|
+
exit_code = 3
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StrictFailure(click.ClickException):
|
|
46
|
+
"""Warnings were raised and `--strict` makes them fatal."""
|
|
47
|
+
|
|
48
|
+
exit_code = 4
|
agentcli/exits_test.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Exit codes are the contract an agent reads instead of prose."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import pytest
|
|
7
|
+
from click.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from agentcli.exits import (
|
|
10
|
+
AssertionFailure,
|
|
11
|
+
RemoteError,
|
|
12
|
+
StrictFailure,
|
|
13
|
+
UsageError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DOCUMENTED = [
|
|
17
|
+
(UsageError, 1),
|
|
18
|
+
(RemoteError, 2),
|
|
19
|
+
(AssertionFailure, 3),
|
|
20
|
+
(StrictFailure, 4),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.parametrize(("error", "code"), DOCUMENTED)
|
|
25
|
+
def test_class_declares_documented_code(error, code) -> None:
|
|
26
|
+
assert error.exit_code == code
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize(("error", "code"), DOCUMENTED)
|
|
30
|
+
def test_raising_exits_with_documented_code(error, code) -> None:
|
|
31
|
+
"""The number a caller sees, not just the one the class declares."""
|
|
32
|
+
|
|
33
|
+
@click.command()
|
|
34
|
+
def cmd() -> None:
|
|
35
|
+
raise error("no")
|
|
36
|
+
|
|
37
|
+
result = CliRunner().invoke(cmd, [])
|
|
38
|
+
|
|
39
|
+
assert result.exit_code == code
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_success_is_zero() -> None:
|
|
43
|
+
@click.command()
|
|
44
|
+
def cmd() -> None:
|
|
45
|
+
click.echo("ok")
|
|
46
|
+
|
|
47
|
+
assert CliRunner().invoke(cmd, []).exit_code == 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_click_parse_failures_are_usage_errors_not_remote_ones() -> None:
|
|
51
|
+
"""A mistyped flag must not look like a network outage to an agent.
|
|
52
|
+
|
|
53
|
+
Click's own `UsageError` defaults to exit 2, the code this project reserves
|
|
54
|
+
for a remote failure. Importing agentcli has to correct that, because a
|
|
55
|
+
tool cannot opt into the convention it forgot to apply.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
@click.command()
|
|
59
|
+
@click.option("--limit", type=click.IntRange(min=0), default=10)
|
|
60
|
+
def cli(limit: int) -> None:
|
|
61
|
+
click.echo(limit)
|
|
62
|
+
|
|
63
|
+
runner = CliRunner()
|
|
64
|
+
assert runner.invoke(cli, ["--limit", "-1"]).exit_code == 1
|
|
65
|
+
assert runner.invoke(cli, ["--nonexistent"]).exit_code == 1
|
|
66
|
+
assert runner.invoke(cli, []).exit_code == 0
|
agentcli/group.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import Any, Literal, NoReturn, overload
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from agentcli.output import emit_error
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JsonAwareGroup(click.Group):
|
|
25
|
+
"""Routes `click.ClickException` to the JSON error shape when asked."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
28
|
+
super().__init__(*args, **kwargs)
|
|
29
|
+
self._json_requested = False
|
|
30
|
+
|
|
31
|
+
# The overloads mirror `click.Group.main` exactly, so a caller holding a
|
|
32
|
+
# `click.Group` can call this the same way, positionals included.
|
|
33
|
+
@overload
|
|
34
|
+
def main(
|
|
35
|
+
self,
|
|
36
|
+
args: Sequence[str] | None = None,
|
|
37
|
+
prog_name: str | None = None,
|
|
38
|
+
complete_var: str | None = None,
|
|
39
|
+
standalone_mode: Literal[True] = True,
|
|
40
|
+
**extra: Any,
|
|
41
|
+
) -> NoReturn: ...
|
|
42
|
+
|
|
43
|
+
@overload
|
|
44
|
+
def main(
|
|
45
|
+
self,
|
|
46
|
+
args: Sequence[str] | None = None,
|
|
47
|
+
prog_name: str | None = None,
|
|
48
|
+
complete_var: str | None = None,
|
|
49
|
+
standalone_mode: bool = ...,
|
|
50
|
+
**extra: Any,
|
|
51
|
+
) -> Any: ...
|
|
52
|
+
|
|
53
|
+
def main(
|
|
54
|
+
self,
|
|
55
|
+
args: Sequence[str] | None = None,
|
|
56
|
+
prog_name: str | None = None,
|
|
57
|
+
complete_var: str | None = None,
|
|
58
|
+
standalone_mode: bool = True,
|
|
59
|
+
**extra: Any,
|
|
60
|
+
) -> Any:
|
|
61
|
+
arguments = list(sys.argv[1:] if args is None else args)
|
|
62
|
+
self._json_requested = "--json" in arguments
|
|
63
|
+
|
|
64
|
+
return super().main(
|
|
65
|
+
arguments,
|
|
66
|
+
prog_name,
|
|
67
|
+
complete_var,
|
|
68
|
+
standalone_mode,
|
|
69
|
+
**extra,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
73
|
+
try:
|
|
74
|
+
return super().invoke(ctx)
|
|
75
|
+
except click.ClickException as exc:
|
|
76
|
+
# The human path stays click's own, which prints the usage block
|
|
77
|
+
# too: worth more to a person than a uniform shape.
|
|
78
|
+
if not self._json_requested:
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
emit_error(exc.format_message(), json_output=True)
|
|
82
|
+
ctx.exit(exc.exit_code)
|
agentcli/group_test.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""A bad flag must still honour `--json`, before click has parsed it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import pytest
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from agentcli import JsonAwareGroup, UsageError, emit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group(cls=JsonAwareGroup)
|
|
15
|
+
def cli() -> None:
|
|
16
|
+
"""Fixture tool."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command("go")
|
|
20
|
+
@click.option("--limit", type=click.IntRange(min=0), default=10)
|
|
21
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
22
|
+
@click.option("--boom", is_flag=True)
|
|
23
|
+
def go(limit: int, json_output: bool, boom: bool) -> None:
|
|
24
|
+
if boom:
|
|
25
|
+
raise UsageError("refused on purpose")
|
|
26
|
+
emit({"limit": limit}, json_output=json_output, human=lambda d: ["ok"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
cli.add_command(go)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(*args: str):
|
|
33
|
+
return CliRunner().invoke(cli, list(args))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_success_is_enveloped() -> None:
|
|
37
|
+
result = _run("go", "--json")
|
|
38
|
+
|
|
39
|
+
assert result.exit_code == 0
|
|
40
|
+
assert json.loads(result.output) == {"ok": True, "data": {"limit": 10}}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_raised_failure_is_enveloped() -> None:
|
|
44
|
+
result = _run("go", "--json", "--boom")
|
|
45
|
+
|
|
46
|
+
assert result.exit_code == 1
|
|
47
|
+
assert json.loads(result.output) == {
|
|
48
|
+
"ok": False,
|
|
49
|
+
"error": {"message": "refused on purpose"},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_parse_failure_is_enveloped_though_json_never_parsed() -> None:
|
|
54
|
+
"""The hard case: click refuses before the subcommand sees --json."""
|
|
55
|
+
result = _run("go", "--json", "--limit", "-1")
|
|
56
|
+
|
|
57
|
+
assert result.exit_code == 1
|
|
58
|
+
payload = json.loads(result.output)
|
|
59
|
+
assert payload["ok"] is False
|
|
60
|
+
assert "-1" in payload["error"]["message"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_unknown_option_is_enveloped() -> None:
|
|
64
|
+
result = _run("go", "--json", "--nonexistent")
|
|
65
|
+
|
|
66
|
+
assert result.exit_code == 1
|
|
67
|
+
assert json.loads(result.output)["ok"] is False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_human_path_keeps_clicks_own_usage_block() -> None:
|
|
71
|
+
"""Without --json a person gets click's output, not a uniform shape."""
|
|
72
|
+
result = _run("go", "--limit", "-1")
|
|
73
|
+
|
|
74
|
+
assert result.exit_code == 1
|
|
75
|
+
assert "Usage:" in result.output
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_main_accepts_clicks_own_positional_arguments(
|
|
79
|
+
capsys: pytest.CaptureFixture[str],
|
|
80
|
+
) -> None:
|
|
81
|
+
"""A caller holding a `click.Group` may pass click's own positionals."""
|
|
82
|
+
group: click.Group = cli
|
|
83
|
+
|
|
84
|
+
group.main(["go", "--json"], "tool", None, False)
|
|
85
|
+
|
|
86
|
+
assert json.loads(capsys.readouterr().out) == {
|
|
87
|
+
"ok": True,
|
|
88
|
+
"data": {"limit": 10},
|
|
89
|
+
}
|
agentcli/guide.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""`<tool> guide` — the manual, shipped inside the binary.
|
|
2
|
+
|
|
3
|
+
The skill file stays a thin router precisely because the detail lives here: a
|
|
4
|
+
skill installed once goes stale, while the guide is upgraded with the package
|
|
5
|
+
that implements it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def guide_command(text: str) -> click.Command:
|
|
14
|
+
"""Build the `guide` subcommand for a tool's own manual text."""
|
|
15
|
+
|
|
16
|
+
@click.command("guide")
|
|
17
|
+
def guide() -> None:
|
|
18
|
+
"""Print the full agent-facing manual for this tool."""
|
|
19
|
+
click.echo(text.strip())
|
|
20
|
+
|
|
21
|
+
return guide
|