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 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