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/__init__.py +43 -0
- agentcli/candidates.py +141 -0
- agentcli/candidates_test.py +195 -0
- agentcli/exits.py +48 -0
- agentcli/exits_test.py +66 -0
- agentcli/group.py +82 -0
- agentcli/group_test.py +89 -0
- agentcli/guide.py +21 -0
- agentcli/guide_test.py +14 -0
- agentcli/output.py +85 -0
- agentcli/output_test.py +91 -0
- agentcli/skill.py +375 -0
- agentcli/skill_test.py +385 -0
- click_agentcli-0.1.0.dist-info/METADATA +116 -0
- click_agentcli-0.1.0.dist-info/RECORD +17 -0
- click_agentcli-0.1.0.dist-info/WHEEL +4 -0
- click_agentcli-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|