click-agentcli 0.2.0__tar.gz → 0.4.1__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 (22) hide show
  1. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/.github/workflows/ci.yml +1 -1
  2. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/PKG-INFO +110 -11
  3. click_agentcli-0.4.1/README.md +190 -0
  4. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/pyproject.toml +3 -2
  5. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/__init__.py +0 -2
  6. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/candidates.py +19 -16
  7. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/candidates_test.py +59 -4
  8. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/group.py +31 -1
  9. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/group_test.py +35 -1
  10. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/skill.py +63 -49
  11. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/skill_test.py +40 -26
  12. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/uv.lock +2 -2
  13. click_agentcli-0.2.0/README.md +0 -92
  14. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/.github/workflows/release.yml +0 -0
  15. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/.gitignore +0 -0
  16. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/LICENSE +0 -0
  17. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/exits.py +0 -0
  18. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/exits_test.py +0 -0
  19. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/guide.py +0 -0
  20. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/guide_test.py +0 -0
  21. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/output.py +0 -0
  22. {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/output_test.py +0 -0
@@ -13,7 +13,7 @@ jobs:
13
13
  strategy:
14
14
  fail-fast: false
15
15
  matrix:
16
- python-version: ["3.13", "3.14"]
16
+ python-version: ["3.12", "3.13", "3.14"]
17
17
 
18
18
  steps:
19
19
  - uses: actions/checkout@v7
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: click-agentcli
3
- Version: 0.2.0
3
+ Version: 0.4.1
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
@@ -13,11 +13,12 @@ Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
16
17
  Classifier: Programming Language :: Python :: 3.13
17
18
  Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: Topic :: Software Development :: Libraries
19
20
  Classifier: Topic :: Utilities
20
- Requires-Python: >=3.13
21
+ Requires-Python: >=3.12
21
22
  Requires-Dist: click>=8.1
22
23
  Description-Content-Type: text/markdown
23
24
 
@@ -27,7 +28,97 @@ Shared conventions for command-line tools whose primary callers are agents.
27
28
  It owns no food domain: it owns predictable errors, JSON output, skills,
28
29
  in-binary guides, and the candidate record used for composition.
29
30
 
30
- ## Install and test
31
+ ## Use it in a package
32
+
33
+ Install the distribution (the import name remains `agentcli`):
34
+
35
+ ```sh
36
+ uv add click-agentcli
37
+ ```
38
+
39
+ Register the shared output, guide, and skill commands on a Click CLI:
40
+
41
+ ```python
42
+ import click
43
+
44
+ from agentcli import (
45
+ JsonAwareGroup,
46
+ emit,
47
+ guide_command,
48
+ json_option,
49
+ skill_group,
50
+ )
51
+
52
+ GUIDE = """# acme guide
53
+
54
+ Use `acme hello` to print a greeting.
55
+ """
56
+
57
+
58
+ @click.group(cls=JsonAwareGroup)
59
+ def cli() -> None:
60
+ """Acme's agent-facing CLI."""
61
+
62
+
63
+ @cli.command()
64
+ @json_option
65
+ def hello(json_output: bool) -> None:
66
+ """Print a greeting."""
67
+ emit(
68
+ {"message": "hello"},
69
+ json_output=json_output,
70
+ human=lambda result: [result["message"]],
71
+ )
72
+
73
+
74
+ cli.add_command(guide_command(GUIDE))
75
+ cli.add_command(skill_group(name="acme", package="acme"))
76
+ ```
77
+
78
+ The command has readable output for people and one stable document for agents:
79
+
80
+ ```console
81
+ $ acme hello
82
+ hello
83
+ $ acme hello --json
84
+ {"ok":true,"data":{"message":"hello"}}
85
+ $ acme guide
86
+ # acme guide
87
+ ...
88
+ $ acme skill install
89
+ copied /home/me/.agents/skills/acme
90
+ ```
91
+
92
+ Declare the entry point and ship the skill inside the import package. For
93
+ Hatchling, a project-root `SKILL.md` can be mapped into the required wheel
94
+ location like this:
95
+
96
+ ```toml
97
+ [project.scripts]
98
+ acme = "acme.cli:cli"
99
+
100
+ [tool.hatch.build.targets.wheel.force-include]
101
+ "SKILL.md" = "acme/skills/acme/SKILL.md"
102
+ ```
103
+
104
+ `skill_group(name="acme", package="acme")` expects an installed wheel to
105
+ contain `acme/skills/acme/SKILL.md`. The same file may stay at the repository
106
+ root for source-checkout use. Its frontmatter name must match the skill name:
107
+
108
+ ```markdown
109
+ ---
110
+ name: acme
111
+ description: Use Acme from an agent.
112
+ ---
113
+
114
+ Run `acme guide` for the complete manual.
115
+ ```
116
+
117
+ Add `@json_option` to each command that supports structured output and call
118
+ `emit` once with both the data and its human renderer. `JsonAwareGroup` then
119
+ keeps parse failures structured when `--json` was requested.
120
+
121
+ ## Develop this package
31
122
 
32
123
  ```sh
33
124
  uv sync --project .
@@ -72,8 +163,12 @@ The stable public surface is:
72
163
  - `JsonAwareGroup` for every consuming tool's top-level group.
73
164
  - `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
74
165
  `status`. Installation refuses an unrelated destination, recognises owned
75
- broken symlinks, copies by default, and supports `--link`, `--to`, and
76
- `--dry-run`. With no options it installs everywhere the skill is wanted
166
+ broken symlinks an older version left, always copies, and supports `--to`
167
+ and `--dry-run`. `status` compares each installed copy against the packaged
168
+ one and reports `current` or `stale`, because upgrading a package never
169
+ refreshes a skill already on disk. 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
77
172
  and refreshes its own earlier copies, so plain `install` is the whole
78
173
  job; a directory holding somebody else's skill is still refused.
79
174
  - `guide_command(text)` for a complete manual available without a network.
@@ -83,7 +178,7 @@ The stable public surface is:
83
178
  ## Candidate contract
84
179
 
85
180
  Candidate sources answer the same question: filter things someone could eat by
86
- per-serving macros, then rank them with provenance. Recipes and restaurant
181
+ per-serving figures, then rank them with provenance. Recipes and restaurant
87
182
  meals therefore emit the same record:
88
183
 
89
184
  ```json
@@ -105,11 +200,15 @@ Sources accept `macro_options` (`--max-kcal`, `--min-protein`) and use `rank`.
105
200
  The rank key is unrounded protein per 100 kcal, then absolute protein, then
106
201
  name. `--max-kcal 0` is valid because zero-calorie records exist.
107
202
 
108
- `per_serving` contains only macros actually known by the source. Missing is
109
- never filled with zero. `complete` exposes whether the full shape is present;
110
- a candidate missing a requested filter macro is excluded and returned in the
111
- source's `unverifiable` or equivalent bucket. Every source emits that bucket,
112
- 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.
113
212
 
114
213
  This contract is the reason the tools can be independent packages: an
115
214
  orchestrator can merge and rank results without knowing which source answered.
@@ -0,0 +1,190 @@
1
+ # agentcli
2
+
3
+ Shared conventions for command-line tools whose primary callers are agents.
4
+ It owns no food domain: it owns predictable errors, JSON output, skills,
5
+ in-binary guides, and the candidate record used for composition.
6
+
7
+ ## Use it in a package
8
+
9
+ Install the distribution (the import name remains `agentcli`):
10
+
11
+ ```sh
12
+ uv add click-agentcli
13
+ ```
14
+
15
+ Register the shared output, guide, and skill commands on a Click CLI:
16
+
17
+ ```python
18
+ import click
19
+
20
+ from agentcli import (
21
+ JsonAwareGroup,
22
+ emit,
23
+ guide_command,
24
+ json_option,
25
+ skill_group,
26
+ )
27
+
28
+ GUIDE = """# acme guide
29
+
30
+ Use `acme hello` to print a greeting.
31
+ """
32
+
33
+
34
+ @click.group(cls=JsonAwareGroup)
35
+ def cli() -> None:
36
+ """Acme's agent-facing CLI."""
37
+
38
+
39
+ @cli.command()
40
+ @json_option
41
+ def hello(json_output: bool) -> None:
42
+ """Print a greeting."""
43
+ emit(
44
+ {"message": "hello"},
45
+ json_output=json_output,
46
+ human=lambda result: [result["message"]],
47
+ )
48
+
49
+
50
+ cli.add_command(guide_command(GUIDE))
51
+ cli.add_command(skill_group(name="acme", package="acme"))
52
+ ```
53
+
54
+ The command has readable output for people and one stable document for agents:
55
+
56
+ ```console
57
+ $ acme hello
58
+ hello
59
+ $ acme hello --json
60
+ {"ok":true,"data":{"message":"hello"}}
61
+ $ acme guide
62
+ # acme guide
63
+ ...
64
+ $ acme skill install
65
+ copied /home/me/.agents/skills/acme
66
+ ```
67
+
68
+ Declare the entry point and ship the skill inside the import package. For
69
+ Hatchling, a project-root `SKILL.md` can be mapped into the required wheel
70
+ location like this:
71
+
72
+ ```toml
73
+ [project.scripts]
74
+ acme = "acme.cli:cli"
75
+
76
+ [tool.hatch.build.targets.wheel.force-include]
77
+ "SKILL.md" = "acme/skills/acme/SKILL.md"
78
+ ```
79
+
80
+ `skill_group(name="acme", package="acme")` expects an installed wheel to
81
+ contain `acme/skills/acme/SKILL.md`. The same file may stay at the repository
82
+ root for source-checkout use. Its frontmatter name must match the skill name:
83
+
84
+ ```markdown
85
+ ---
86
+ name: acme
87
+ description: Use Acme from an agent.
88
+ ---
89
+
90
+ Run `acme guide` for the complete manual.
91
+ ```
92
+
93
+ Add `@json_option` to each command that supports structured output and call
94
+ `emit` once with both the data and its human renderer. `JsonAwareGroup` then
95
+ keeps parse failures structured when `--json` was requested.
96
+
97
+ ## Develop this package
98
+
99
+ ```sh
100
+ uv sync --project .
101
+ uv run --project . pytest -q
102
+ ```
103
+
104
+ ## CLI contract
105
+
106
+ Every consuming tool uses `click`, declares `--json` per command with
107
+ `json_option`, and makes its top-level group `JsonAwareGroup`. The group scans
108
+ raw arguments so even parse failures that happen before a subcommand exists
109
+ honour a `--json` request. Importing `agentcli.exits` also changes Click's own
110
+ usage-error code from 2 to 1; consumers must not repeat that correction.
111
+
112
+ | code | meaning |
113
+ | --- | --- |
114
+ | 0 | success |
115
+ | 1 | usage error or a caller-liftable refusal |
116
+ | 2 | remote, network, or site failure after allowed retries |
117
+ | 3 | a caller-stated assertion did not hold |
118
+ | 4 | a data-quality warning escalated by `--strict` |
119
+
120
+ An exhausted request budget is code 1, because the caller can lift it. A
121
+ proportional recipe fit with no solution is code 3.
122
+
123
+ `--json` emits exactly one JSON object on stdout and nothing else. Success and
124
+ failure are symmetric:
125
+
126
+ ```json
127
+ {"ok":true,"data":{}}
128
+ {"ok":false,"error":{"message":"..."}}
129
+ ```
130
+
131
+ A search with no matches is successful with an empty list. Under `--json`,
132
+ errors go to stdout so a caller never has to merge streams to recover the one
133
+ promised document. Human errors go to stderr.
134
+
135
+ The stable public surface is:
136
+
137
+ - `UsageError`, `RemoteError`, `AssertionFailure`, and `StrictFailure`.
138
+ - `dumps`, `emit`, `emit_error`, `json_option`, and `limit_option`.
139
+ - `JsonAwareGroup` for every consuming tool's top-level group.
140
+ - `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
141
+ `status`. Installation refuses an unrelated destination, recognises owned
142
+ broken symlinks an older version left, always copies, and supports `--to`
143
+ and `--dry-run`. `status` compares each installed copy against the packaged
144
+ one and reports `current` or `stale`, because upgrading a package never
145
+ refreshes a skill already on disk. It never links: a link points into the environment, whose
146
+ path carries the interpreter version, so a rebuild elsewhere leaves the
147
+ skill silently absent rather than merely stale. With no options it installs everywhere the skill is wanted
148
+ and refreshes its own earlier copies, so plain `install` is the whole
149
+ job; a directory holding somebody else's skill is still refused.
150
+ - `guide_command(text)` for a complete manual available without a network.
151
+ - `candidate`, `macro_options`, `matches`, `rank`, and `unverifiable` for the
152
+ shared composition record and filters below.
153
+
154
+ ## Candidate contract
155
+
156
+ Candidate sources answer the same question: filter things someone could eat by
157
+ per-serving figures, then rank them with provenance. Recipes and restaurant
158
+ meals therefore emit the same record:
159
+
160
+ ```json
161
+ {
162
+ "kind":"recipe",
163
+ "id":"sourdough-pizza",
164
+ "name":"Sourdough Pizza",
165
+ "per_serving":{"kcal":384.2,"protein":31.5,"fat":12.1,"carbs":38.4},
166
+ "complete":true,
167
+ "detail":{}
168
+ }
169
+ ```
170
+
171
+ `kind` is `recipe` or `meal`. `id` is accepted back by the emitting tool;
172
+ display-only slugs are not identifiers. Source-specific fields live under
173
+ `detail`, which shared code never reads.
174
+
175
+ Sources accept `macro_options` (`--max-kcal`, `--min-protein`) and use `rank`.
176
+ The rank key is unrounded protein per 100 kcal, then absolute protein, then
177
+ name. `--max-kcal 0` is valid because zero-calorie records exist.
178
+
179
+ `per_serving` contains only figures actually known by the source, whatever
180
+ they are — a source that also publishes fibre or sodium puts them here.
181
+ Missing is never filled with zero. `candidate` takes the keys that count as a
182
+ full set from its caller as `required`, because this package does not know what
183
+ a macro is; callers answering the same question share that tuple so `complete`
184
+ keeps meaning the same thing across them. A candidate missing a requested
185
+ filter figure is excluded and returned in the source's `unverifiable` or
186
+ equivalent bucket. Every source emits that bucket, even when its loader makes
187
+ it structurally empty.
188
+
189
+ This contract is the reason the tools can be independent packages: an
190
+ orchestrator can merge and rank results without knowing which source answered.
@@ -10,10 +10,10 @@ 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.2.0"
13
+ version = "0.4.1"
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
- requires-python = ">=3.13"
16
+ requires-python = ">=3.12"
17
17
  license = "MIT"
18
18
  authors = [{ name = "owahltinez" }]
19
19
  keywords = ["click", "cli", "agent", "skill", "json"]
@@ -24,6 +24,7 @@ classifiers = [
24
24
  "License :: OSI Approved :: MIT License",
25
25
  "Operating System :: OS Independent",
26
26
  "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.12",
27
28
  "Programming Language :: Python :: 3.13",
28
29
  "Programming Language :: Python :: 3.14",
29
30
  "Topic :: Software Development :: Libraries",
@@ -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,
@@ -22,7 +21,6 @@ from agentcli.skill import skill_group
22
21
 
23
22
  __all__ = [
24
23
  "KINDS",
25
- "MACRO_KEYS",
26
24
  "AssertionFailure",
27
25
  "JsonAwareGroup",
28
26
  "RemoteError",
@@ -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
@@ -67,14 +67,44 @@ class JsonAwareGroup(click.Group):
67
67
  **extra,
68
68
  )
69
69
 
70
+ def _drift_hint(self, ctx: click.Context, exc: Exception) -> str:
71
+ """Why a name this caller expected might not exist.
72
+
73
+ A caller reading a skill older than the binary asks for a command the
74
+ binary has since renamed or dropped, and no other failure looks like
75
+ this. The hint lives here rather than in the skill because the skill
76
+ is the thing that went stale, while the binary is what was upgraded.
77
+ """
78
+ if not isinstance(exc, click.NoSuchOption | click.UsageError):
79
+ return ""
80
+ if "No such command" not in str(exc) and not isinstance(
81
+ exc, click.NoSuchOption
82
+ ):
83
+ return ""
84
+ if "skill" not in self.commands:
85
+ return ""
86
+ tool = ctx.find_root().info_name or "this tool"
87
+ return (
88
+ f" If this was documented, the installed skill predates this "
89
+ f"version; run `{tool} skill install`."
90
+ )
91
+
70
92
  def invoke(self, ctx: click.Context) -> Any:
71
93
  try:
72
94
  return super().invoke(ctx)
73
95
  except click.ClickException as exc:
96
+ hint = self._drift_hint(ctx, exc)
74
97
  # The human path stays click's own, which prints the usage block
75
98
  # too: worth more to a person than a uniform shape.
76
99
  if not self._json_requested:
100
+ if hint:
101
+ # Re-raised rather than edited: click marks the message
102
+ # final, and a UsageError still prints usage and exits 2.
103
+ raise click.UsageError(
104
+ f"{exc.format_message()}{hint}",
105
+ ctx=getattr(exc, "ctx", None),
106
+ ) from exc
77
107
  raise
78
108
 
79
- emit_error(exc.format_message(), json_output=True)
109
+ emit_error(f"{exc.format_message()}{hint}", json_output=True)
80
110
  ctx.exit(exc.exit_code)
@@ -6,7 +6,7 @@ import click
6
6
  import pytest
7
7
  from click.testing import CliRunner
8
8
 
9
- from agentcli import JsonAwareGroup, UsageError, emit
9
+ from agentcli import JsonAwareGroup, UsageError, emit, skill_group
10
10
 
11
11
 
12
12
  @click.group(cls=JsonAwareGroup)
@@ -85,3 +85,37 @@ def test_main_accepts_clicks_own_positional_arguments(
85
85
  "ok": True,
86
86
  "data": {"limit": 10},
87
87
  }
88
+
89
+
90
+ @click.group(cls=JsonAwareGroup)
91
+ def skilled() -> None:
92
+ """A tool that ships a skill."""
93
+
94
+
95
+ skilled.add_command(go)
96
+ skilled.add_command(skill_group(name="faketool", package="agentcli"))
97
+
98
+
99
+ @pytest.mark.parametrize(
100
+ ("args", "hinted"),
101
+ [
102
+ (["go", "--nope"], True),
103
+ (["nosuchcommand"], True),
104
+ (["go", "--limit"], False),
105
+ (["go", "--limit", "-1"], False),
106
+ ],
107
+ )
108
+ def test_an_unknown_name_suggests_a_stale_skill(args, hinted: bool) -> None:
109
+ """A caller reading a skill older than the binary asks for a name the
110
+ binary dropped. No other failure looks like that, so nothing else hints."""
111
+ result = CliRunner().invoke(skilled, args)
112
+
113
+ assert result.exit_code != 0
114
+ assert ("skill install" in result.output) is hinted
115
+
116
+
117
+ def test_a_tool_without_a_skill_suggests_nothing() -> None:
118
+ result = CliRunner().invoke(cli, ["nosuchcommand"])
119
+
120
+ assert result.exit_code != 0
121
+ assert "skill install" not in result.output
@@ -96,9 +96,9 @@ def _primary_target(destination: Path | None, home: Path, name: str) -> Path:
96
96
  def _is_our_skill(target: Path, *, name: str) -> bool:
97
97
  """Does this directory actually hold the skill we installed?
98
98
 
99
- A broken symlink counts. `--link` into a `uvx` environment dies on
100
- `uv cache prune`, and refusing to clean up exactly that wreckage would be
101
- perverse -- the directory is still one this tool created.
99
+ A broken symlink counts. Older versions could install one, and refusing to
100
+ clean up exactly that wreckage would be perverse -- the directory is still
101
+ one this tool created.
102
102
  """
103
103
  manifest = target / "SKILL.md"
104
104
  if manifest.is_symlink() and not manifest.exists():
@@ -151,14 +151,15 @@ def _refusal(target: Path, *, name: str) -> str | None:
151
151
  return None
152
152
 
153
153
 
154
- def _place(source: Path, target: Path, *, name: str, link: bool) -> str:
154
+ def _place(source: Path, target: Path, *, name: str) -> str:
155
155
  """Place SKILL.md into a skill directory of its own.
156
156
 
157
- Copying is the default because a link points into the environment this CLI
158
- was installed into: run under `uvx`, that is a prunable cache, so the skill
159
- works today and vanishes after `uv cache prune`. Copying costs a stale
160
- skill after an upgrade, which is cheap here -- the skill is a router, and
161
- the manual it routes to (`<tool> guide`) ships in the binary.
157
+ Always a copy. A link points into the environment this CLI was installed
158
+ into, and that path is not stable: it carries the interpreter version, so
159
+ an environment rebuilt on another Python leaves a dangling link and the
160
+ skill silently disappears. Copying costs a stale skill after an upgrade,
161
+ which is the louder failure and the cheaper one -- the skill is a router,
162
+ and the manual it routes to (`<tool> guide`) ships in the binary.
162
163
  """
163
164
  refusal = _refusal(target, name=name)
164
165
  if refusal is not None:
@@ -167,27 +168,37 @@ def _place(source: Path, target: Path, *, name: str, link: bool) -> str:
167
168
  if target.exists() or target.is_symlink():
168
169
  _remove(target)
169
170
 
170
- # The directory is always real, and named for the skill as the spec
171
- # requires; only its contents are ever linked.
171
+ # The directory is named for the skill, as the spec requires.
172
172
  target.mkdir(parents=True, exist_ok=True)
173
- manifest = target / "SKILL.md"
174
-
175
- if link:
176
- try:
177
- manifest.symlink_to(source)
178
- except OSError as exc:
179
- # Windows needs Developer Mode or admin rights for symlinks. A
180
- # copy is a worse answer than a link but a much better one than
181
- # a traceback.
182
- click.echo(f"# symlink failed ({exc}); copying instead", err=True)
183
- else:
184
- return f"linked {manifest} -> {source}"
185
-
186
- shutil.copy2(source, manifest)
173
+ shutil.copy2(source, target / "SKILL.md")
187
174
  return f"copied {target}"
188
175
 
189
176
 
190
- def _status_rows(home: Path, name: str) -> list[dict[str, Any]]:
177
+ def _state(path: Path, source: Path | None, *, name: str) -> str:
178
+ """Whether a location holds this skill, and whether it is the current one.
179
+
180
+ Presence alone said `installed` for a copy many releases old, because
181
+ upgrading a package never refreshes a skill already on disk. Comparing the
182
+ bytes is what makes that drift visible instead of silent.
183
+ """
184
+ if not _is_our_skill(path, name=name):
185
+ return "absent"
186
+ if source is None or not source.is_file():
187
+ return "installed"
188
+ installed = path / "SKILL.md"
189
+ try:
190
+ return (
191
+ "current"
192
+ if installed.read_bytes() == source.read_bytes()
193
+ else "stale"
194
+ )
195
+ except OSError:
196
+ return "installed"
197
+
198
+
199
+ def _status_rows(
200
+ home: Path, name: str, source: Path | None = None
201
+ ) -> list[dict[str, Any]]:
191
202
  """One row per known location, shared first, whether present or not."""
192
203
  locations = [("Shared (.agents)", home / SHARED_DIR / name)]
193
204
  locations += [
@@ -195,22 +206,32 @@ def _status_rows(home: Path, name: str) -> list[dict[str, Any]]:
195
206
  for label, (_, skills) in TOOL_DIRS.items()
196
207
  ]
197
208
 
198
- return [
199
- {
200
- "tool": label,
201
- "path": str(path),
202
- "installed": _is_our_skill(path, name=name),
203
- }
204
- for label, path in locations
205
- ]
209
+ rows = []
210
+ for label, path in locations:
211
+ state = _state(path, source, name=name)
212
+ rows.append(
213
+ {
214
+ "tool": label,
215
+ "path": str(path),
216
+ "installed": state != "absent",
217
+ "state": state,
218
+ }
219
+ )
220
+ return rows
206
221
 
207
222
 
208
223
  def _status_lines(payload: dict[str, Any]) -> Iterable[str]:
209
224
  """Render `status` for a human: fixed columns, no table drawing."""
210
225
  for row in payload["locations"]:
211
- mark = "installed" if row["installed"] else "-"
226
+ mark = "-" if row["state"] == "absent" else row["state"]
212
227
  yield f"{mark:<10} {row['tool']:<16} {row['path']}"
213
228
 
229
+ if any(row["state"] == "stale" for row in payload["locations"]):
230
+ yield (
231
+ f"A stale copy predates this version. Run "
232
+ f"`{payload['skill']} skill install` to refresh it."
233
+ )
234
+
214
235
 
215
236
  def skill_group(*, name: str, package: str) -> click.Group:
216
237
  """Build the `skill` command group for one tool.
@@ -235,8 +256,7 @@ def skill_group(*, name: str, package: str) -> click.Group:
235
256
  \b
236
257
  {name} skill install # everywhere it is wanted
237
258
  {name} skill install --to ~/.claude/skills # just this one
238
- {name} skill install --to .agents/skills # this repository only
239
- {name} skill install --link # track package upgrades""",
259
+ {name} skill install --to .agents/skills # this repository only""",
240
260
  )
241
261
  @click.option(
242
262
  "--to",
@@ -244,19 +264,9 @@ def skill_group(*, name: str, package: str) -> click.Group:
244
264
  type=click.Path(file_okay=False, path_type=Path),
245
265
  help=target_help,
246
266
  )
247
- @click.option(
248
- "--link",
249
- is_flag=True,
250
- help="Symlink instead of copying, so package upgrades take effect "
251
- "immediately. Only safe for a durable install and a private skills "
252
- "directory: a link into a `uvx` environment dies on `uv cache "
253
- "prune`, and one committed to a repository is broken for everyone "
254
- "else. Re-running install is the portable way to refresh.",
255
- )
256
267
  @click.option("--dry-run", is_flag=True, help=dry_run_help)
257
268
  def install_command(
258
269
  destination: Path | None,
259
- link: bool,
260
270
  dry_run: bool,
261
271
  ) -> None:
262
272
  """Install the Agent Skill into an agent's skills directory.
@@ -291,7 +301,7 @@ def skill_group(*, name: str, package: str) -> click.Group:
291
301
  continue
292
302
 
293
303
  try:
294
- click.echo(_place(source, target, name=name, link=link))
304
+ click.echo(_place(source, target, name=name))
295
305
  except OSError as exc:
296
306
  raise click.ClickException(
297
307
  f"could not install into {target}: {exc.strerror or exc}"
@@ -364,9 +374,13 @@ def skill_group(*, name: str, package: str) -> click.Group:
364
374
  @json_option
365
375
  def status_command(json_output: bool) -> None:
366
376
  """Show every known location and whether the skill is installed."""
377
+ try:
378
+ source: Path | None = packaged_skill(name=name, package=package)
379
+ except click.ClickException:
380
+ source = None
367
381
  payload = {
368
382
  "skill": name,
369
- "locations": _status_rows(Path.home(), name),
383
+ "locations": _status_rows(Path.home(), name, source),
370
384
  }
371
385
  emit(payload, json_output=json_output, human=_status_lines)
372
386
 
@@ -138,13 +138,13 @@ def test_install_refreshes_our_own_copy(tool: Tool) -> None:
138
138
  assert (tool.shared() / "SKILL.md").read_text() == MANIFEST
139
139
 
140
140
 
141
- def test_install_link_makes_a_symlink(tool: Tool) -> None:
141
+ def test_install_refuses_to_link(tool: Tool) -> None:
142
+ """A link points into the environment, whose path carries the interpreter
143
+ version, so a rebuild elsewhere leaves the skill silently absent."""
142
144
  result = tool.run("install", "--link")
143
145
 
144
- assert result.exit_code == 0
145
- manifest = tool.shared() / "SKILL.md"
146
- assert manifest.is_symlink()
147
- assert manifest.resolve() == tool.source.resolve()
146
+ assert result.exit_code != 0
147
+ assert "--link" in result.output
148
148
 
149
149
 
150
150
  def test_install_covers_detected_tools_without_a_flag(tool: Tool) -> None:
@@ -228,10 +228,12 @@ def test_uninstall_refuses_a_foreign_directory(tool: Tool) -> None:
228
228
 
229
229
 
230
230
  def test_uninstall_removes_a_broken_symlink(tool: Tool) -> None:
231
- """A `--link` install survived `uv cache prune`; clean it up anyway."""
232
- tool.run("install", "--link")
233
- tool.source.unlink()
231
+ """A link an older version installed, now dangling. Still ours to clean."""
232
+ tool.run("install")
234
233
  manifest = tool.shared() / "SKILL.md"
234
+ manifest.unlink()
235
+ manifest.symlink_to(tool.source)
236
+ tool.source.unlink()
235
237
  assert manifest.is_symlink() and not manifest.exists()
236
238
 
237
239
  result = tool.run("uninstall")
@@ -300,24 +302,6 @@ def test_status_human_lists_every_location(tool: Tool) -> None:
300
302
  assert "installed" not in result.output
301
303
 
302
304
 
303
- def test_install_link_failure_falls_back_to_copying(
304
- tool: Tool, monkeypatch: pytest.MonkeyPatch
305
- ) -> None:
306
- """Windows without Developer Mode: a copy beats a traceback."""
307
-
308
- def refuse(self, target, **kwargs):
309
- raise OSError("symlinks need a privilege you do not have")
310
-
311
- monkeypatch.setattr(Path, "symlink_to", refuse)
312
-
313
- result = tool.run("install", "--link")
314
-
315
- assert result.exit_code == 0
316
- manifest = tool.shared() / "SKILL.md"
317
- assert not manifest.is_symlink()
318
- assert manifest.read_text() == MANIFEST
319
-
320
-
321
305
  def test_install_wraps_oserror(
322
306
  tool: Tool, monkeypatch: pytest.MonkeyPatch
323
307
  ) -> None:
@@ -381,3 +365,33 @@ def test_uninstall_matches_what_install_wrote(tool: Tool) -> None:
381
365
  Path(".gemini") / "config" / "skills",
382
366
  ):
383
367
  assert not (tool.home / relative / NAME).exists()
368
+
369
+
370
+ def test_status_calls_a_matching_copy_current(tool: Tool) -> None:
371
+ tool.run("install")
372
+
373
+ result = tool.run("status", "--json")
374
+
375
+ rows = json.loads(result.output)["data"]["locations"]
376
+ shared = next(r for r in rows if r["tool"].startswith("Shared"))
377
+ assert shared["state"] == "current"
378
+
379
+
380
+ def test_status_reports_a_copy_the_package_has_moved_past(tool: Tool) -> None:
381
+ """Upgrading a package never refreshes a skill already on disk, so
382
+ presence alone reported `installed` for a copy releases out of date."""
383
+ tool.run("install")
384
+ tool.source.write_text(MANIFEST + "a section added since\n")
385
+
386
+ result = tool.run("status")
387
+
388
+ assert "stale" in result.output
389
+ assert "skill install" in result.output
390
+
391
+
392
+ def test_status_still_answers_for_an_absent_location(tool: Tool) -> None:
393
+ result = tool.run("status", "--json")
394
+
395
+ rows = json.loads(result.output)["data"]["locations"]
396
+ assert all(row["state"] == "absent" for row in rows)
397
+ assert all(row["installed"] is False for row in rows)
@@ -1,6 +1,6 @@
1
1
  version = 1
2
2
  revision = 3
3
- requires-python = ">=3.13"
3
+ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "click"
@@ -16,7 +16,7 @@ wheels = [
16
16
 
17
17
  [[package]]
18
18
  name = "click-agentcli"
19
- version = "0.2.0"
19
+ version = "0.4.1"
20
20
  source = { editable = "." }
21
21
  dependencies = [
22
22
  { name = "click" },
@@ -1,92 +0,0 @@
1
- # agentcli
2
-
3
- Shared conventions for command-line tools whose primary callers are agents.
4
- It owns no food domain: it owns predictable errors, JSON output, skills,
5
- in-binary guides, and the candidate record used for composition.
6
-
7
- ## Install and test
8
-
9
- ```sh
10
- uv sync --project .
11
- uv run --project . pytest -q
12
- ```
13
-
14
- ## CLI contract
15
-
16
- Every consuming tool uses `click`, declares `--json` per command with
17
- `json_option`, and makes its top-level group `JsonAwareGroup`. The group scans
18
- raw arguments so even parse failures that happen before a subcommand exists
19
- honour a `--json` request. Importing `agentcli.exits` also changes Click's own
20
- usage-error code from 2 to 1; consumers must not repeat that correction.
21
-
22
- | code | meaning |
23
- | --- | --- |
24
- | 0 | success |
25
- | 1 | usage error or a caller-liftable refusal |
26
- | 2 | remote, network, or site failure after allowed retries |
27
- | 3 | a caller-stated assertion did not hold |
28
- | 4 | a data-quality warning escalated by `--strict` |
29
-
30
- An exhausted request budget is code 1, because the caller can lift it. A
31
- proportional recipe fit with no solution is code 3.
32
-
33
- `--json` emits exactly one JSON object on stdout and nothing else. Success and
34
- failure are symmetric:
35
-
36
- ```json
37
- {"ok":true,"data":{}}
38
- {"ok":false,"error":{"message":"..."}}
39
- ```
40
-
41
- A search with no matches is successful with an empty list. Under `--json`,
42
- errors go to stdout so a caller never has to merge streams to recover the one
43
- promised document. Human errors go to stderr.
44
-
45
- The stable public surface is:
46
-
47
- - `UsageError`, `RemoteError`, `AssertionFailure`, and `StrictFailure`.
48
- - `dumps`, `emit`, `emit_error`, `json_option`, and `limit_option`.
49
- - `JsonAwareGroup` for every consuming tool's top-level group.
50
- - `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
51
- `status`. Installation refuses an unrelated destination, recognises owned
52
- broken symlinks, copies by default, and supports `--link`, `--to`, and
53
- `--dry-run`. With no options it installs everywhere the skill is wanted
54
- and refreshes its own earlier copies, so plain `install` is the whole
55
- job; a directory holding somebody else's skill is still refused.
56
- - `guide_command(text)` for a complete manual available without a network.
57
- - `candidate`, `macro_options`, `matches`, `rank`, and `unverifiable` for the
58
- shared composition record and filters below.
59
-
60
- ## Candidate contract
61
-
62
- Candidate sources answer the same question: filter things someone could eat by
63
- per-serving macros, then rank them with provenance. Recipes and restaurant
64
- meals therefore emit the same record:
65
-
66
- ```json
67
- {
68
- "kind":"recipe",
69
- "id":"sourdough-pizza",
70
- "name":"Sourdough Pizza",
71
- "per_serving":{"kcal":384.2,"protein":31.5,"fat":12.1,"carbs":38.4},
72
- "complete":true,
73
- "detail":{}
74
- }
75
- ```
76
-
77
- `kind` is `recipe` or `meal`. `id` is accepted back by the emitting tool;
78
- display-only slugs are not identifiers. Source-specific fields live under
79
- `detail`, which shared code never reads.
80
-
81
- Sources accept `macro_options` (`--max-kcal`, `--min-protein`) and use `rank`.
82
- The rank key is unrounded protein per 100 kcal, then absolute protein, then
83
- name. `--max-kcal 0` is valid because zero-calorie records exist.
84
-
85
- `per_serving` contains only macros actually known by the source. Missing is
86
- never filled with zero. `complete` exposes whether the full shape is present;
87
- a candidate missing a requested filter macro is excluded and returned in the
88
- source's `unverifiable` or equivalent bucket. Every source emits that bucket,
89
- even when its loader makes it structurally empty.
90
-
91
- This contract is the reason the tools can be independent packages: an
92
- orchestrator can merge and rank results without knowing which source answered.
File without changes