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.
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/.github/workflows/ci.yml +1 -1
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/PKG-INFO +110 -11
- click_agentcli-0.4.1/README.md +190 -0
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/pyproject.toml +3 -2
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/__init__.py +0 -2
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/candidates.py +19 -16
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/candidates_test.py +59 -4
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/group.py +31 -1
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/group_test.py +35 -1
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/skill.py +63 -49
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/skill_test.py +40 -26
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/uv.lock +2 -2
- click_agentcli-0.2.0/README.md +0 -92
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/.github/workflows/release.yml +0 -0
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/.gitignore +0 -0
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/LICENSE +0 -0
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/exits.py +0 -0
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/exits_test.py +0 -0
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/guide.py +0 -0
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/guide_test.py +0 -0
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/output.py +0 -0
- {click_agentcli-0.2.0 → click_agentcli-0.4.1}/src/agentcli/output_test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: click-agentcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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.
|
|
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
|
-
##
|
|
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
|
|
76
|
-
`--dry-run`.
|
|
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
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
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.
|
|
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
|
|
35
|
-
A
|
|
36
|
-
never measured is not a fat-free dish, and `complete`
|
|
37
|
-
apart.
|
|
33
|
+
`per_serving` carries every figure the source published, whatever they are,
|
|
34
|
+
and omits the rest. A figure is never defaulted to zero to fill the shape:
|
|
35
|
+
a dish whose fat was never measured is not a fat-free dish, and `complete`
|
|
36
|
+
is what tells them apart.
|
|
37
|
+
|
|
38
|
+
`required` is the caller's -- which figures it considers a full set. This
|
|
39
|
+
module does not know what a macro is, and a source that publishes fibre or
|
|
40
|
+
sodium should be able to say so without asking permission here. Callers
|
|
41
|
+
that answer the same question share the tuple so `complete` keeps meaning
|
|
42
|
+
the same thing across them.
|
|
38
43
|
"""
|
|
39
44
|
if kind not in KINDS:
|
|
40
45
|
raise ValueError(f"unknown candidate kind: {kind}")
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
key: per_serving
|
|
44
|
-
for key in MACRO_KEYS
|
|
45
|
-
if per_serving.get(key) is not None
|
|
47
|
+
published = {
|
|
48
|
+
key: value for key, value in per_serving.items() if value is not None
|
|
46
49
|
}
|
|
47
50
|
return {
|
|
48
51
|
"kind": kind,
|
|
49
52
|
"id": identifier,
|
|
50
53
|
"name": name,
|
|
51
|
-
"per_serving":
|
|
52
|
-
"complete":
|
|
54
|
+
"per_serving": published,
|
|
55
|
+
"complete": all(key in published for key in required),
|
|
53
56
|
"detail": detail or {},
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -76,15 +79,15 @@ def matches(
|
|
|
76
79
|
) -> bool:
|
|
77
80
|
"""Whether a candidate provably satisfies the constraints.
|
|
78
81
|
|
|
79
|
-
A candidate missing the
|
|
82
|
+
A candidate missing the figure a filter asks about is excluded, because it
|
|
80
83
|
cannot be shown to pass. Callers report those separately rather than
|
|
81
84
|
dropping them silently — "no results" and "three results I could not check"
|
|
82
85
|
are different answers.
|
|
83
86
|
"""
|
|
84
|
-
|
|
85
|
-
kcal, protein =
|
|
87
|
+
published = record["per_serving"]
|
|
88
|
+
kcal, protein = published.get("kcal"), published.get("protein")
|
|
86
89
|
|
|
87
|
-
# A missing
|
|
90
|
+
# A missing figure fails the filter that asks about it rather than being
|
|
88
91
|
# treated as zero, which would pass every ceiling and fail every floor.
|
|
89
92
|
over = max_kcal is not None and (kcal is None or kcal > max_kcal)
|
|
90
93
|
under = min_protein is not None and (
|
|
@@ -17,6 +17,10 @@ from agentcli.candidates import (
|
|
|
17
17
|
FULL = {"kcal": 384.2, "protein": 31.5, "fat": 12.1, "carbs": 38.4}
|
|
18
18
|
PARTIAL = {"kcal": 540.0, "protein": 45.8, "fat": None, "carbs": None}
|
|
19
19
|
|
|
20
|
+
# What the nutrition tools happen to require. This module does not define it --
|
|
21
|
+
# it is a caller's tuple, and these tests stand in for a caller.
|
|
22
|
+
REQUIRED = ("kcal", "protein", "fat", "carbs")
|
|
23
|
+
|
|
20
24
|
|
|
21
25
|
def make(kind: str = "recipe", name: str = "Thing", **macros: object) -> dict:
|
|
22
26
|
return candidate(
|
|
@@ -24,13 +28,18 @@ def make(kind: str = "recipe", name: str = "Thing", **macros: object) -> dict:
|
|
|
24
28
|
identifier=name.lower(),
|
|
25
29
|
name=name,
|
|
26
30
|
per_serving=macros or FULL,
|
|
31
|
+
required=REQUIRED,
|
|
27
32
|
)
|
|
28
33
|
|
|
29
34
|
|
|
30
35
|
def test_an_unpublished_macro_is_omitted_rather_than_zeroed() -> None:
|
|
31
36
|
"""A dish whose fat was never measured is not a fat-free dish."""
|
|
32
37
|
partial = candidate(
|
|
33
|
-
kind="meal",
|
|
38
|
+
kind="meal",
|
|
39
|
+
identifier="x",
|
|
40
|
+
name="Bowl",
|
|
41
|
+
per_serving=PARTIAL,
|
|
42
|
+
required=REQUIRED,
|
|
34
43
|
)
|
|
35
44
|
|
|
36
45
|
assert partial["per_serving"] == {"kcal": 540.0, "protein": 45.8}
|
|
@@ -38,11 +47,44 @@ def test_an_unpublished_macro_is_omitted_rather_than_zeroed() -> None:
|
|
|
38
47
|
assert partial["complete"] is False
|
|
39
48
|
|
|
40
49
|
|
|
41
|
-
def
|
|
50
|
+
def test_complete_means_every_figure_the_caller_required() -> None:
|
|
42
51
|
assert make()["complete"] is True
|
|
43
52
|
assert make(kcal=1.0, protein=1.0, fat=1.0)["complete"] is False
|
|
44
53
|
|
|
45
54
|
|
|
55
|
+
def test_a_figure_the_caller_did_not_require_is_still_carried() -> None:
|
|
56
|
+
"""A source publishing fibre should not have to ask permission here.
|
|
57
|
+
|
|
58
|
+
Filtering to a fixed set of keys meant a tool that had resolved a fibre
|
|
59
|
+
figure could not publish it, and an agent following the documented rule
|
|
60
|
+
-- look for it in `per_serving` -- concluded it was unavailable.
|
|
61
|
+
"""
|
|
62
|
+
record = candidate(
|
|
63
|
+
kind="recipe",
|
|
64
|
+
identifier="soup",
|
|
65
|
+
name="Soup",
|
|
66
|
+
per_serving={**FULL, "dietary_fiber": 4.1, "sodium": 0.4},
|
|
67
|
+
required=REQUIRED,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
assert record["per_serving"]["dietary_fiber"] == 4.1
|
|
71
|
+
assert record["per_serving"]["sodium"] == 0.4
|
|
72
|
+
assert record["complete"] is True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_a_caller_requiring_less_is_complete_with_less() -> None:
|
|
76
|
+
"""Two tools answer different questions; neither defines the other's set."""
|
|
77
|
+
record = candidate(
|
|
78
|
+
kind="meal",
|
|
79
|
+
identifier="x",
|
|
80
|
+
name="X",
|
|
81
|
+
per_serving={"kcal": 540.0, "protein": 45.8},
|
|
82
|
+
required=("kcal", "protein"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
assert record["complete"] is True
|
|
86
|
+
|
|
87
|
+
|
|
46
88
|
def test_detail_is_where_the_kinds_differ() -> None:
|
|
47
89
|
"""Nothing shared reads `detail`, so the kinds cannot collide in it."""
|
|
48
90
|
meal = candidate(
|
|
@@ -50,6 +92,7 @@ def test_detail_is_where_the_kinds_differ() -> None:
|
|
|
50
92
|
identifier="crust-margherita",
|
|
51
93
|
name="Margherita",
|
|
52
94
|
per_serving=FULL,
|
|
95
|
+
required=REQUIRED,
|
|
53
96
|
detail={"restaurant": "Crust Pizza", "distance_km": 1.5},
|
|
54
97
|
)
|
|
55
98
|
|
|
@@ -67,7 +110,13 @@ def test_detail_is_where_the_kinds_differ() -> None:
|
|
|
67
110
|
def test_an_unknown_kind_is_refused() -> None:
|
|
68
111
|
"""A third kind is a decision, not something a caller slips in."""
|
|
69
112
|
with pytest.raises(ValueError, match="unknown candidate kind"):
|
|
70
|
-
candidate(
|
|
113
|
+
candidate(
|
|
114
|
+
kind="snack",
|
|
115
|
+
identifier="x",
|
|
116
|
+
name="X",
|
|
117
|
+
per_serving=FULL,
|
|
118
|
+
required=REQUIRED,
|
|
119
|
+
)
|
|
71
120
|
|
|
72
121
|
|
|
73
122
|
@pytest.mark.parametrize(
|
|
@@ -102,7 +151,11 @@ def test_a_missing_macro_fails_the_filter_that_asks_about_it() -> None:
|
|
|
102
151
|
|
|
103
152
|
def test_no_filters_matches_everything_including_incomplete() -> None:
|
|
104
153
|
partial = candidate(
|
|
105
|
-
kind="meal",
|
|
154
|
+
kind="meal",
|
|
155
|
+
identifier="x",
|
|
156
|
+
name="Bowl",
|
|
157
|
+
per_serving=PARTIAL,
|
|
158
|
+
required=REQUIRED,
|
|
106
159
|
)
|
|
107
160
|
|
|
108
161
|
assert matches(partial) is True
|
|
@@ -170,12 +223,14 @@ def test_a_published_zero_is_not_a_missing_measurement() -> None:
|
|
|
170
223
|
identifier="coffee",
|
|
171
224
|
name="Black Coffee",
|
|
172
225
|
per_serving={"kcal": 0.0, "protein": 0.0, "fat": 0.0, "carbs": 0.0},
|
|
226
|
+
required=REQUIRED,
|
|
173
227
|
)
|
|
174
228
|
unmeasured = candidate(
|
|
175
229
|
kind="meal",
|
|
176
230
|
identifier="mystery",
|
|
177
231
|
name="Mystery",
|
|
178
232
|
per_serving={"protein": 0.0, "fat": 0.0, "carbs": 0.0},
|
|
233
|
+
required=REQUIRED,
|
|
179
234
|
)
|
|
180
235
|
|
|
181
236
|
assert measured["per_serving"]["kcal"] == 0.0
|
|
@@ -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.
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
skill
|
|
161
|
-
the
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 = "
|
|
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
|
|
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
|
|
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
|
|
145
|
-
|
|
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
|
|
232
|
-
tool.run("install"
|
|
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.
|
|
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.
|
|
19
|
+
version = "0.4.1"
|
|
20
20
|
source = { editable = "." }
|
|
21
21
|
dependencies = [
|
|
22
22
|
{ name = "click" },
|
click_agentcli-0.2.0/README.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|