click-agentcli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- click_agentcli-0.1.0/.github/workflows/ci.yml +33 -0
- click_agentcli-0.1.0/.github/workflows/release.yml +60 -0
- click_agentcli-0.1.0/.gitignore +8 -0
- click_agentcli-0.1.0/LICENSE +21 -0
- click_agentcli-0.1.0/PKG-INFO +116 -0
- click_agentcli-0.1.0/README.md +92 -0
- click_agentcli-0.1.0/pyproject.toml +49 -0
- click_agentcli-0.1.0/src/agentcli/__init__.py +43 -0
- click_agentcli-0.1.0/src/agentcli/candidates.py +141 -0
- click_agentcli-0.1.0/src/agentcli/candidates_test.py +195 -0
- click_agentcli-0.1.0/src/agentcli/exits.py +48 -0
- click_agentcli-0.1.0/src/agentcli/exits_test.py +66 -0
- click_agentcli-0.1.0/src/agentcli/group.py +82 -0
- click_agentcli-0.1.0/src/agentcli/group_test.py +89 -0
- click_agentcli-0.1.0/src/agentcli/guide.py +21 -0
- click_agentcli-0.1.0/src/agentcli/guide_test.py +14 -0
- click_agentcli-0.1.0/src/agentcli/output.py +85 -0
- click_agentcli-0.1.0/src/agentcli/output_test.py +91 -0
- click_agentcli-0.1.0/src/agentcli/skill.py +375 -0
- click_agentcli-0.1.0/src/agentcli/skill_test.py +385 -0
- click_agentcli-0.1.0/uv.lock +124 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["main"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: ["main"]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Test on Python ${{ matrix.python-version }}
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v7
|
|
20
|
+
|
|
21
|
+
# Pinned exactly: setup-uv dropped its floating major tags after v7,
|
|
22
|
+
# so v8 and later only exist as exact versions.
|
|
23
|
+
- name: Install uv
|
|
24
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
25
|
+
with:
|
|
26
|
+
version: "latest"
|
|
27
|
+
enable-cache: true
|
|
28
|
+
|
|
29
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
30
|
+
run: uv python install ${{ matrix.python-version }}
|
|
31
|
+
|
|
32
|
+
- name: Run test suite
|
|
33
|
+
run: uv run --python ${{ matrix.python-version }} pytest
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
name: Test & Verify Suite
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v7
|
|
14
|
+
|
|
15
|
+
# Pinned exactly: setup-uv dropped its floating major tags after v7,
|
|
16
|
+
# so v8 and later only exist as exact versions.
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
19
|
+
with:
|
|
20
|
+
version: "latest"
|
|
21
|
+
enable-cache: true
|
|
22
|
+
|
|
23
|
+
- name: Set up Python
|
|
24
|
+
run: uv python install 3.12
|
|
25
|
+
|
|
26
|
+
- name: Run test suite
|
|
27
|
+
run: uv run pytest
|
|
28
|
+
|
|
29
|
+
build-and-publish:
|
|
30
|
+
name: Build & Publish to PyPI
|
|
31
|
+
needs: test
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
environment:
|
|
34
|
+
name: pypi
|
|
35
|
+
url: https://pypi.org/p/click-agentcli
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write # Required for PyPI Trusted Publishing (OIDC)
|
|
38
|
+
contents: write # Required for GitHub Release creation
|
|
39
|
+
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@v7
|
|
42
|
+
with:
|
|
43
|
+
fetch-depth: 0
|
|
44
|
+
|
|
45
|
+
- name: Install uv
|
|
46
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
47
|
+
with:
|
|
48
|
+
version: "latest"
|
|
49
|
+
|
|
50
|
+
- name: Build distributions (sdist & wheel)
|
|
51
|
+
run: uv build
|
|
52
|
+
|
|
53
|
+
- name: Publish package distributions to PyPI
|
|
54
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
55
|
+
|
|
56
|
+
- name: Create GitHub Release
|
|
57
|
+
uses: softprops/action-gh-release@v3
|
|
58
|
+
with:
|
|
59
|
+
files: dist/*
|
|
60
|
+
generate_release_notes: true
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 owahltinez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: click-agentcli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared CLI conventions for agent-facing tools: exit codes, JSON output, skill installation, and the in-binary guide.
|
|
5
|
+
Project-URL: Homepage, https://github.com/owahltinez/click-agentcli
|
|
6
|
+
Author: owahltinez
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agent,cli,click,json,skill
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: click>=8.1
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# agentcli
|
|
26
|
+
|
|
27
|
+
Shared conventions for command-line tools whose primary callers are agents.
|
|
28
|
+
It owns no food domain: it owns predictable errors, JSON output, skills,
|
|
29
|
+
in-binary guides, and the candidate record used for composition.
|
|
30
|
+
|
|
31
|
+
## Install and test
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
uv sync --project .
|
|
35
|
+
uv run --project . pytest -q
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## CLI contract
|
|
39
|
+
|
|
40
|
+
Every consuming tool uses `click`, declares `--json` per command with
|
|
41
|
+
`json_option`, and makes its top-level group `JsonAwareGroup`. The group scans
|
|
42
|
+
raw arguments so even parse failures that happen before a subcommand exists
|
|
43
|
+
honour a `--json` request. Importing `agentcli.exits` also changes Click's own
|
|
44
|
+
usage-error code from 2 to 1; consumers must not repeat that correction.
|
|
45
|
+
|
|
46
|
+
| code | meaning |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| 0 | success |
|
|
49
|
+
| 1 | usage error or a caller-liftable refusal |
|
|
50
|
+
| 2 | remote, network, or site failure after allowed retries |
|
|
51
|
+
| 3 | a caller-stated assertion did not hold |
|
|
52
|
+
| 4 | a data-quality warning escalated by `--strict` |
|
|
53
|
+
|
|
54
|
+
An exhausted request budget is code 1, because the caller can lift it. A
|
|
55
|
+
proportional recipe fit with no solution is code 3.
|
|
56
|
+
|
|
57
|
+
`--json` emits exactly one JSON object on stdout and nothing else. Success and
|
|
58
|
+
failure are symmetric:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{"ok":true,"data":{}}
|
|
62
|
+
{"ok":false,"error":{"message":"..."}}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A search with no matches is successful with an empty list. Under `--json`,
|
|
66
|
+
errors go to stdout so a caller never has to merge streams to recover the one
|
|
67
|
+
promised document. Human errors go to stderr.
|
|
68
|
+
|
|
69
|
+
The stable public surface is:
|
|
70
|
+
|
|
71
|
+
- `UsageError`, `RemoteError`, `AssertionFailure`, and `StrictFailure`.
|
|
72
|
+
- `dumps`, `emit`, `emit_error`, `json_option`, and `limit_option`.
|
|
73
|
+
- `JsonAwareGroup` for every consuming tool's top-level group.
|
|
74
|
+
- `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
|
|
75
|
+
`status`. Installation refuses an unrelated destination, recognises owned
|
|
76
|
+
broken symlinks, copies by default, and supports `--link`, `--to`, and
|
|
77
|
+
`--dry-run`. With no options it installs everywhere the skill is wanted
|
|
78
|
+
and refreshes its own earlier copies, so plain `install` is the whole
|
|
79
|
+
job; a directory holding somebody else's skill is still refused.
|
|
80
|
+
- `guide_command(text)` for a complete manual available without a network.
|
|
81
|
+
- `candidate`, `macro_options`, `matches`, `rank`, and `unverifiable` for the
|
|
82
|
+
shared composition record and filters below.
|
|
83
|
+
|
|
84
|
+
## Candidate contract
|
|
85
|
+
|
|
86
|
+
Candidate sources answer the same question: filter things someone could eat by
|
|
87
|
+
per-serving macros, then rank them with provenance. Recipes and restaurant
|
|
88
|
+
meals therefore emit the same record:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"kind":"recipe",
|
|
93
|
+
"id":"sourdough-pizza",
|
|
94
|
+
"name":"Sourdough Pizza",
|
|
95
|
+
"per_serving":{"kcal":384.2,"protein":31.5,"fat":12.1,"carbs":38.4},
|
|
96
|
+
"complete":true,
|
|
97
|
+
"detail":{}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`kind` is `recipe` or `meal`. `id` is accepted back by the emitting tool;
|
|
102
|
+
display-only slugs are not identifiers. Source-specific fields live under
|
|
103
|
+
`detail`, which shared code never reads.
|
|
104
|
+
|
|
105
|
+
Sources accept `macro_options` (`--max-kcal`, `--min-protein`) and use `rank`.
|
|
106
|
+
The rank key is unrounded protein per 100 kcal, then absolute protein, then
|
|
107
|
+
name. `--max-kcal 0` is valid because zero-calorie records exist.
|
|
108
|
+
|
|
109
|
+
`per_serving` contains only macros actually known by the source. Missing is
|
|
110
|
+
never filled with zero. `complete` exposes whether the full shape is present;
|
|
111
|
+
a candidate missing a requested filter macro is excluded and returned in the
|
|
112
|
+
source's `unverifiable` or equivalent bucket. Every source emits that bucket,
|
|
113
|
+
even when its loader makes it structurally empty.
|
|
114
|
+
|
|
115
|
+
This contract is the reason the tools can be independent packages: an
|
|
116
|
+
orchestrator can merge and rank results without knowing which source answered.
|
|
@@ -0,0 +1,92 @@
|
|
|
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.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# The distribution name differs from the import name: PyPI rejects
|
|
7
|
+
# "agentcli" as too similar to the existing "agent-cli". Importers still
|
|
8
|
+
# write `import agentcli`.
|
|
9
|
+
#
|
|
10
|
+
# Dependents must name the distribution, not the import, in both their
|
|
11
|
+
# dependencies and their [tool.uv.sources] key.
|
|
12
|
+
name = "click-agentcli"
|
|
13
|
+
version = "0.1.0"
|
|
14
|
+
description = "Shared CLI conventions for agent-facing tools: exit codes, JSON output, skill installation, and the in-binary guide."
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
requires-python = ">=3.11"
|
|
17
|
+
license = "MIT"
|
|
18
|
+
authors = [{ name = "owahltinez" }]
|
|
19
|
+
keywords = ["click", "cli", "agent", "skill", "json"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Software Development :: Libraries",
|
|
31
|
+
"Topic :: Utilities",
|
|
32
|
+
]
|
|
33
|
+
dependencies = ["click>=8.1"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/owahltinez/click-agentcli"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = ["pytest>=8.0", "ruff>=0.6"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/agentcli"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["src/agentcli"]
|
|
46
|
+
python_files = ["*_test.py"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 79
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared conventions for agent-facing CLI tools."""
|
|
2
|
+
|
|
3
|
+
from agentcli.candidates import (
|
|
4
|
+
KINDS,
|
|
5
|
+
MACRO_KEYS,
|
|
6
|
+
candidate,
|
|
7
|
+
macro_options,
|
|
8
|
+
matches,
|
|
9
|
+
rank,
|
|
10
|
+
unverifiable,
|
|
11
|
+
)
|
|
12
|
+
from agentcli.exits import (
|
|
13
|
+
AssertionFailure,
|
|
14
|
+
RemoteError,
|
|
15
|
+
StrictFailure,
|
|
16
|
+
UsageError,
|
|
17
|
+
)
|
|
18
|
+
from agentcli.group import JsonAwareGroup
|
|
19
|
+
from agentcli.guide import guide_command
|
|
20
|
+
from agentcli.output import dumps, emit, emit_error, json_option, limit_option
|
|
21
|
+
from agentcli.skill import skill_group
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"KINDS",
|
|
25
|
+
"MACRO_KEYS",
|
|
26
|
+
"AssertionFailure",
|
|
27
|
+
"JsonAwareGroup",
|
|
28
|
+
"RemoteError",
|
|
29
|
+
"StrictFailure",
|
|
30
|
+
"UsageError",
|
|
31
|
+
"candidate",
|
|
32
|
+
"dumps",
|
|
33
|
+
"emit",
|
|
34
|
+
"emit_error",
|
|
35
|
+
"guide_command",
|
|
36
|
+
"json_option",
|
|
37
|
+
"limit_option",
|
|
38
|
+
"macro_options",
|
|
39
|
+
"matches",
|
|
40
|
+
"rank",
|
|
41
|
+
"skill_group",
|
|
42
|
+
"unverifiable",
|
|
43
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""A thing you could eat, and the macros you would decide on.
|
|
2
|
+
|
|
3
|
+
Two questions have the same shape — "what can I cook under 400 kcal a serving
|
|
4
|
+
with 30 g of protein" and "where can I eat out under 800 kcal with 35 g" — so
|
|
5
|
+
the tools that answer them emit the same record. A recipe and a restaurant dish
|
|
6
|
+
differ in how they came to exist and in what detail they can show, not in what
|
|
7
|
+
a decision needs from them.
|
|
8
|
+
|
|
9
|
+
That shared part lives here so an orchestrator merges two JSON streams and
|
|
10
|
+
ranks, instead of special-casing each tool. Everything kind-specific goes under
|
|
11
|
+
`detail`, which nothing shared ever reads.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
MACRO_KEYS = ("kcal", "protein", "fat", "carbs")
|
|
22
|
+
|
|
23
|
+
KINDS = ("recipe", "meal")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def candidate(
|
|
27
|
+
*,
|
|
28
|
+
kind: str,
|
|
29
|
+
identifier: str,
|
|
30
|
+
name: str,
|
|
31
|
+
per_serving: dict[str, float | None],
|
|
32
|
+
detail: dict[str, Any] | None = None,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""One comparable option, per serving.
|
|
35
|
+
|
|
36
|
+
`per_serving` carries every macro the source published and omits the rest.
|
|
37
|
+
A macro is never defaulted to zero to fill the shape: a dish whose fat was
|
|
38
|
+
never measured is not a fat-free dish, and `complete` is what tells them
|
|
39
|
+
apart.
|
|
40
|
+
"""
|
|
41
|
+
if kind not in KINDS:
|
|
42
|
+
raise ValueError(f"unknown candidate kind: {kind}")
|
|
43
|
+
|
|
44
|
+
macros = {
|
|
45
|
+
key: per_serving[key]
|
|
46
|
+
for key in MACRO_KEYS
|
|
47
|
+
if per_serving.get(key) is not None
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
"kind": kind,
|
|
51
|
+
"id": identifier,
|
|
52
|
+
"name": name,
|
|
53
|
+
"per_serving": macros,
|
|
54
|
+
"complete": len(macros) == len(MACRO_KEYS),
|
|
55
|
+
"detail": detail or {},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def macro_options(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
60
|
+
"""The two filters every candidate source accepts, spelled identically."""
|
|
61
|
+
f = click.option(
|
|
62
|
+
"--min-protein",
|
|
63
|
+
type=click.FloatRange(min=0),
|
|
64
|
+
help="Least protein, in grams per serving.",
|
|
65
|
+
)(f)
|
|
66
|
+
return click.option(
|
|
67
|
+
"--max-kcal",
|
|
68
|
+
type=click.FloatRange(min=0),
|
|
69
|
+
help="Most energy, in kcal per serving.",
|
|
70
|
+
)(f)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def matches(
|
|
74
|
+
record: dict[str, Any],
|
|
75
|
+
*,
|
|
76
|
+
max_kcal: float | None = None,
|
|
77
|
+
min_protein: float | None = None,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Whether a candidate provably satisfies the constraints.
|
|
80
|
+
|
|
81
|
+
A candidate missing the macro a filter asks about is excluded, because it
|
|
82
|
+
cannot be shown to pass. Callers report those separately rather than
|
|
83
|
+
dropping them silently — "no results" and "three results I could not check"
|
|
84
|
+
are different answers.
|
|
85
|
+
"""
|
|
86
|
+
macros = record["per_serving"]
|
|
87
|
+
kcal, protein = macros.get("kcal"), macros.get("protein")
|
|
88
|
+
|
|
89
|
+
# A missing macro fails the filter that asks about it rather than being
|
|
90
|
+
# treated as zero, which would pass every ceiling and fail every floor.
|
|
91
|
+
over = max_kcal is not None and (kcal is None or kcal > max_kcal)
|
|
92
|
+
under = min_protein is not None and (
|
|
93
|
+
protein is None or protein < min_protein
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return not (over or under)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def unverifiable(
|
|
100
|
+
record: dict[str, Any],
|
|
101
|
+
*,
|
|
102
|
+
max_kcal: float | None = None,
|
|
103
|
+
min_protein: float | None = None,
|
|
104
|
+
) -> bool:
|
|
105
|
+
"""Whether a filter was asked about a macro this candidate lacks."""
|
|
106
|
+
macros = record["per_serving"]
|
|
107
|
+
return (max_kcal is not None and macros.get("kcal") is None) or (
|
|
108
|
+
min_protein is not None and macros.get("protein") is None
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def rank(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
113
|
+
"""Protein per 100 kcal, descending. Ties break by name, not file order.
|
|
114
|
+
|
|
115
|
+
One ranking for both kinds, so a merged list is ordered the same way
|
|
116
|
+
whoever produced it. Deterministic on every machine: no locale collation.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def key(record: dict[str, Any]) -> tuple[float, float, str]:
|
|
120
|
+
macros = record["per_serving"]
|
|
121
|
+
|
|
122
|
+
# `or 0.0` would be wrong here, and wrong in the one file that defines
|
|
123
|
+
# the missing-value contract: a published 0 kcal is a fact about black
|
|
124
|
+
# coffee, not an absent measurement. They coincide in the arithmetic
|
|
125
|
+
# below but must not coincide in the idiom.
|
|
126
|
+
kcal = macros.get("kcal")
|
|
127
|
+
protein = macros.get("protein")
|
|
128
|
+
|
|
129
|
+
density = (
|
|
130
|
+
protein / kcal * 100
|
|
131
|
+
if kcal is not None and protein is not None and kcal > 0
|
|
132
|
+
else 0.0
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
-density,
|
|
137
|
+
-(protein if protein is not None else 0.0),
|
|
138
|
+
record["name"],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return sorted(records, key=key)
|