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.
@@ -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,8 @@
1
+ .env
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ *.egg-info/
@@ -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)