click-agentcli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentcli/skill_test.py ADDED
@@ -0,0 +1,385 @@
1
+ """`skill install|uninstall|status`, exercised against a throwaway home.
2
+
3
+ No test may touch a real `~/.agents` or `~/.claude`, so every one of them runs
4
+ against a `tmp_path` home and a fake installed tool.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import json
11
+ import shutil
12
+ import sys
13
+ from collections.abc import Iterator
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import click
18
+ import pytest
19
+ from click.testing import CliRunner
20
+
21
+ from agentcli.skill import (
22
+ SHARED_DIR,
23
+ detected_tools,
24
+ packaged_skill,
25
+ skill_group,
26
+ )
27
+
28
+ NAME = "faketool"
29
+ MANIFEST = f"---\nname: {NAME}\ndescription: router\n---\nbody\n"
30
+ FOREIGN = "---\nname: someone-elses-skill\n---\nimportant work\n"
31
+
32
+
33
+ @dataclass
34
+ class Tool:
35
+ """A fake installed tool: importable package, checkout SKILL.md, home."""
36
+
37
+ runner: CliRunner
38
+ cli: click.Group
39
+ home: Path
40
+ source: Path
41
+ root: Path
42
+
43
+ def run(self, *args: str):
44
+ return self.runner.invoke(self.cli, list(args))
45
+
46
+ def shared(self) -> Path:
47
+ return self.home / SHARED_DIR / NAME
48
+
49
+
50
+ @pytest.fixture
51
+ def tool(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Tool]:
52
+ """Build the fake tool and point `Path.home` at a throwaway directory."""
53
+ root = tmp_path / "checkout"
54
+ package = root / "src" / NAME
55
+ package.mkdir(parents=True)
56
+ (package / "__init__.py").write_text("")
57
+ source = root / "SKILL.md"
58
+ source.write_text(MANIFEST)
59
+
60
+ # The package has to be genuinely importable: `packaged_skill` resolves it
61
+ # through `importlib.resources`, which is the part worth testing.
62
+ monkeypatch.syspath_prepend(str(root / "src"))
63
+ sys.modules.pop(NAME, None)
64
+ importlib.invalidate_caches()
65
+
66
+ home = tmp_path / "home"
67
+ home.mkdir()
68
+ monkeypatch.setattr(Path, "home", lambda: home)
69
+
70
+ yield Tool(
71
+ runner=CliRunner(),
72
+ cli=skill_group(name=NAME, package=NAME),
73
+ home=home,
74
+ source=source,
75
+ root=root,
76
+ )
77
+
78
+ sys.modules.pop(NAME, None)
79
+
80
+
81
+ def _install_foreign(target: Path) -> Path:
82
+ """Someone else's skill directory, which we must never touch."""
83
+ target.mkdir(parents=True)
84
+ manifest = target / "SKILL.md"
85
+ manifest.write_text(FOREIGN)
86
+ return manifest
87
+
88
+
89
+ def test_packaged_skill_prefers_the_wheel_copy(tool: Tool) -> None:
90
+ packaged = tool.root / "src" / NAME / "skills" / NAME / "SKILL.md"
91
+ packaged.parent.mkdir(parents=True)
92
+ packaged.write_text(MANIFEST)
93
+
94
+ assert packaged_skill(name=NAME, package=NAME) == packaged
95
+
96
+
97
+ def test_packaged_skill_falls_back_to_the_checkout(tool: Tool) -> None:
98
+ assert packaged_skill(name=NAME, package=NAME) == tool.source
99
+
100
+
101
+ def test_packaged_skill_reports_where_it_looked(tool: Tool) -> None:
102
+ tool.source.unlink()
103
+
104
+ with pytest.raises(Exception, match="SKILL.md not found"):
105
+ packaged_skill(name=NAME, package=NAME)
106
+
107
+
108
+ def test_install_into_a_fresh_home(tool: Tool) -> None:
109
+ result = tool.run("install")
110
+
111
+ assert result.exit_code == 0
112
+ assert (tool.shared() / "SKILL.md").read_text() == MANIFEST
113
+ # Copy, not link, so `uv cache prune` cannot take the skill with it.
114
+ assert not (tool.shared() / "SKILL.md").is_symlink()
115
+
116
+
117
+ def test_install_refuses_a_foreign_directory(tool: Tool) -> None:
118
+ skills = tool.home / "elsewhere"
119
+ manifest = _install_foreign(skills / NAME)
120
+
121
+ result = tool.run("install", "--to", str(skills))
122
+
123
+ assert result.exit_code == 1
124
+ assert "does not contain the faketool skill" in result.output
125
+ assert manifest.read_text() == FOREIGN
126
+
127
+
128
+ def test_install_refreshes_our_own_copy(tool: Tool) -> None:
129
+ """Re-running install is how a stale skill gets updated, so it must work.
130
+
131
+ The packaged manifest is the source of truth, which makes replacing our
132
+ own copy idempotent rather than destructive.
133
+ """
134
+ tool.run("install")
135
+ (tool.shared() / "SKILL.md").write_text("stale\nname: faketool\n")
136
+
137
+ result = tool.run("install")
138
+
139
+ assert result.exit_code == 0
140
+ assert (tool.shared() / "SKILL.md").read_text() == MANIFEST
141
+
142
+
143
+ def test_install_link_makes_a_symlink(tool: Tool) -> None:
144
+ result = tool.run("install", "--link")
145
+
146
+ assert result.exit_code == 0
147
+ manifest = tool.shared() / "SKILL.md"
148
+ assert manifest.is_symlink()
149
+ assert manifest.resolve() == tool.source.resolve()
150
+
151
+
152
+ def test_install_covers_detected_tools_without_a_flag(tool: Tool) -> None:
153
+ """Installing a skill everywhere it is wanted is the whole job."""
154
+ (tool.home / ".claude").mkdir()
155
+ (tool.home / ".gemini").mkdir()
156
+
157
+ result = tool.run("install")
158
+
159
+ assert result.exit_code == 0
160
+ for relative in (
161
+ SHARED_DIR,
162
+ Path(".claude") / "skills",
163
+ Path(".gemini") / "skills",
164
+ Path(".gemini") / "config" / "skills",
165
+ ):
166
+ assert (tool.home / relative / NAME / "SKILL.md").is_file()
167
+ # An undetected tool is never created.
168
+ assert not (tool.home / ".cursor").exists()
169
+
170
+
171
+ def test_install_all_dedupes_an_overlapping_to(tool: Tool) -> None:
172
+ """`--to` naming a swept directory must not refuse itself."""
173
+ (tool.home / ".claude").mkdir()
174
+ skills = tool.home / ".claude" / "skills"
175
+
176
+ result = tool.run("install", "--to", str(skills))
177
+
178
+ assert result.exit_code == 0
179
+ assert result.output.count(str(skills / NAME)) == 1
180
+
181
+
182
+ def test_install_dry_run_touches_nothing(tool: Tool) -> None:
183
+ result = tool.run("install", "--dry-run")
184
+
185
+ assert result.exit_code == 0
186
+ assert "would install" in result.output
187
+ assert not (tool.home / ".agents").exists()
188
+
189
+
190
+ def test_install_dry_run_predicts_a_foreign_refusal(tool: Tool) -> None:
191
+ skills = tool.home / "elsewhere"
192
+ _install_foreign(skills / NAME)
193
+
194
+ result = tool.run("install", "--to", str(skills), "--dry-run")
195
+
196
+ assert result.exit_code == 0
197
+ assert "would REFUSE" in result.output
198
+ assert "does not contain the faketool skill" in result.output
199
+
200
+
201
+ def test_install_dry_run_predicts_a_refresh(tool: Tool) -> None:
202
+ """Re-installing over our own copy is a plain install, not a refusal."""
203
+ tool.run("install")
204
+
205
+ result = tool.run("install", "--dry-run")
206
+
207
+ assert result.exit_code == 0
208
+ assert "would install" in result.output
209
+ assert "would REFUSE" not in result.output
210
+
211
+
212
+ def test_uninstall_removes_our_own(tool: Tool) -> None:
213
+ tool.run("install")
214
+
215
+ result = tool.run("uninstall")
216
+
217
+ assert result.exit_code == 0
218
+ assert not tool.shared().exists()
219
+
220
+
221
+ def test_uninstall_refuses_a_foreign_directory(tool: Tool) -> None:
222
+ skills = tool.home / "elsewhere"
223
+ manifest = _install_foreign(skills / NAME)
224
+
225
+ result = tool.run("uninstall", "--to", str(skills))
226
+
227
+ assert result.exit_code == 1
228
+ assert "refusing to delete it" in result.output
229
+ assert manifest.read_text() == FOREIGN
230
+
231
+
232
+ def test_uninstall_removes_a_broken_symlink(tool: Tool) -> None:
233
+ """A `--link` install survived `uv cache prune`; clean it up anyway."""
234
+ tool.run("install", "--link")
235
+ tool.source.unlink()
236
+ manifest = tool.shared() / "SKILL.md"
237
+ assert manifest.is_symlink() and not manifest.exists()
238
+
239
+ result = tool.run("uninstall")
240
+
241
+ assert result.exit_code == 0
242
+ assert not tool.shared().exists()
243
+
244
+
245
+ def test_uninstall_sweeps_uninstalled_tools(tool: Tool) -> None:
246
+ """A removed tool can leave a skill behind; that is what needs sweeping."""
247
+ stale = tool.home / ".cursor" / "skills" / NAME
248
+ stale.mkdir(parents=True)
249
+ (stale / "SKILL.md").write_text(MANIFEST)
250
+
251
+ result = tool.run("uninstall")
252
+
253
+ assert result.exit_code == 0
254
+ assert not stale.exists()
255
+
256
+
257
+ def test_uninstall_dry_run_touches_nothing(tool: Tool) -> None:
258
+ tool.run("install")
259
+
260
+ result = tool.run("uninstall", "--dry-run")
261
+
262
+ assert result.exit_code == 0
263
+ assert "would remove" in result.output
264
+ assert (tool.shared() / "SKILL.md").is_file()
265
+
266
+
267
+ def test_uninstall_reports_an_empty_sweep(tool: Tool) -> None:
268
+ result = tool.run("uninstall")
269
+
270
+ assert result.exit_code == 0
271
+ assert result.output.strip() == "nothing to remove"
272
+
273
+
274
+ def test_detected_tools_needs_the_marker(tool: Tool) -> None:
275
+ assert detected_tools(tool.home) == {}
276
+
277
+ (tool.home / ".gemini").mkdir()
278
+
279
+ assert set(detected_tools(tool.home)) == {"Gemini CLI", "Antigravity"}
280
+
281
+
282
+ def test_status_json_is_one_object(tool: Tool) -> None:
283
+ tool.run("install")
284
+
285
+ result = tool.run("status", "--json")
286
+
287
+ assert result.exit_code == 0
288
+ assert len(result.output.strip().splitlines()) == 1
289
+ envelope = json.loads(result.output)
290
+ assert envelope["ok"] is True
291
+ payload = envelope["data"]
292
+ assert payload["skill"] == NAME
293
+ installed = [r for r in payload["locations"] if r["installed"]]
294
+ assert [r["path"] for r in installed] == [str(tool.shared())]
295
+
296
+
297
+ def test_status_human_lists_every_location(tool: Tool) -> None:
298
+ result = tool.run("status")
299
+
300
+ assert result.exit_code == 0
301
+ assert "Claude Code" in result.output
302
+ assert "installed" not in result.output
303
+
304
+
305
+ def test_install_link_failure_falls_back_to_copying(
306
+ tool: Tool, monkeypatch: pytest.MonkeyPatch
307
+ ) -> None:
308
+ """Windows without Developer Mode: a copy beats a traceback."""
309
+
310
+ def refuse(self, target, **kwargs):
311
+ raise OSError("symlinks need a privilege you do not have")
312
+
313
+ monkeypatch.setattr(Path, "symlink_to", refuse)
314
+
315
+ result = tool.run("install", "--link")
316
+
317
+ assert result.exit_code == 0
318
+ manifest = tool.shared() / "SKILL.md"
319
+ assert not manifest.is_symlink()
320
+ assert manifest.read_text() == MANIFEST
321
+
322
+
323
+ def test_install_wraps_oserror(
324
+ tool: Tool, monkeypatch: pytest.MonkeyPatch
325
+ ) -> None:
326
+ """A full disk is an error message, not a stack trace."""
327
+
328
+ def refuse(source, target):
329
+ raise OSError(28, "No space left on device")
330
+
331
+ monkeypatch.setattr(shutil, "copy2", refuse)
332
+
333
+ result = tool.run("install")
334
+
335
+ assert result.exit_code == 1
336
+ assert "could not install into" in result.output
337
+ assert "No space left on device" in result.output
338
+
339
+
340
+ def test_uninstall_wraps_oserror(
341
+ tool: Tool, monkeypatch: pytest.MonkeyPatch
342
+ ) -> None:
343
+ tool.run("install")
344
+
345
+ def refuse(path):
346
+ raise OSError(13, "Permission denied")
347
+
348
+ monkeypatch.setattr(shutil, "rmtree", refuse)
349
+
350
+ result = tool.run("uninstall")
351
+
352
+ assert result.exit_code == 1
353
+ assert "could not remove" in result.output
354
+ assert "Permission denied" in result.output
355
+
356
+
357
+ def test_install_to_stays_scoped_to_one_directory(tool: Tool) -> None:
358
+ """`--to` is how you opt out of the sweep."""
359
+ (tool.home / ".claude").mkdir()
360
+ only = tool.home / "only"
361
+
362
+ result = tool.run("install", "--to", str(only))
363
+
364
+ assert result.exit_code == 0
365
+ assert (only / NAME / "SKILL.md").is_file()
366
+ assert not (tool.home / ".claude" / "skills").exists()
367
+ assert not tool.shared().exists()
368
+
369
+
370
+ def test_uninstall_matches_what_install_wrote(tool: Tool) -> None:
371
+ """An asymmetric default would strand copies install had made."""
372
+ (tool.home / ".claude").mkdir()
373
+ (tool.home / ".gemini").mkdir()
374
+ tool.run("install")
375
+
376
+ result = tool.run("uninstall")
377
+
378
+ assert result.exit_code == 0
379
+ for relative in (
380
+ SHARED_DIR,
381
+ Path(".claude") / "skills",
382
+ Path(".gemini") / "skills",
383
+ Path(".gemini") / "config" / "skills",
384
+ ):
385
+ assert not (tool.home / relative / NAME).exists()
@@ -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,17 @@
1
+ agentcli/__init__.py,sha256=Rger_jPCXj-JPvSBWlq7ka7Qgl3a8MVLPI79rKWXfrI,855
2
+ agentcli/candidates.py,sha256=StcZ5SERMRihRtTfUGG-PKW5jGwwlQUvRGnXTsH-cSI,4532
3
+ agentcli/candidates_test.py,sha256=_uFxXJLK-4u1w9c9gapB_u1dPWWGrbwnuCLzYGSmfhY,6352
4
+ agentcli/exits.py,sha256=BDjdncmNgvC2PtYN19KUawTZowNV5MG8NSpxkEZdUIg,1602
5
+ agentcli/exits_test.py,sha256=DJizSrQDJfs8xSK1LxmP2s7tBbNLQjJMZJg7lDUg9lI,1775
6
+ agentcli/group.py,sha256=RZpnbebPkh2ny6i7HtWoKxbd2wXDPAbFvtupqSqXPFQ,2590
7
+ agentcli/group_test.py,sha256=SCySCilYVnqa-vgYIhOhqCMf5Ep48Q2rsWOg0VziHqw,2392
8
+ agentcli/guide.py,sha256=azKyaFabfvxM84FYbbYsM0UbTUJOo7WXEKxIwkhoBq0,579
9
+ agentcli/guide_test.py,sha256=A1GWekxj7Rs7qogFFgY2UkLER1b-AaMmaTGHSnKh1_M,369
10
+ agentcli/output.py,sha256=JBwpM8cbLkoS5w9yQCZ4ueF55NQQ0h_bGBZ_XbokSaM,2658
11
+ agentcli/output_test.py,sha256=nVF7xQr1g4fSJ527LX1EnffVPmzFrrdb5MLE_eswXKA,2444
12
+ agentcli/skill.py,sha256=rsZidUAHxky3sL16ug2VaKsoF413bh3vnETFkZ9obV8,13777
13
+ agentcli/skill_test.py,sha256=vOWJ5HZi7LzoVSNAe5ObaWMMPiz45QG92cEWftVHf4k,11297
14
+ click_agentcli-0.1.0.dist-info/METADATA,sha256=tXAtkx-NW_JdNZAMhLbinoAR9lbWMSWX_ez_NvPFVQs,4627
15
+ click_agentcli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
16
+ click_agentcli-0.1.0.dist-info/licenses/LICENSE,sha256=LHFhXRfOqppxH3pCE8mWWr3RP_FWivgx5qgh927IFwM,1067
17
+ click_agentcli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.