click-agentcli 0.4.1__tar.gz → 0.5.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.4.1 → click_agentcli-0.5.0}/PKG-INFO +28 -4
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/README.md +27 -3
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/pyproject.toml +1 -1
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/__init__.py +3 -1
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/group.py +34 -0
- click_agentcli-0.5.0/src/agentcli/group_test.py +231 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/skill.py +80 -14
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/skill_test.py +88 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/uv.lock +1 -1
- click_agentcli-0.4.1/src/agentcli/group_test.py +0 -121
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/.github/workflows/ci.yml +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/.github/workflows/release.yml +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/.gitignore +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/LICENSE +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/candidates.py +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/candidates_test.py +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/exits.py +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/exits_test.py +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/guide.py +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/guide_test.py +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/output.py +0 -0
- {click_agentcli-0.4.1 → click_agentcli-0.5.0}/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.5.0
|
|
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
|
|
@@ -118,6 +118,29 @@ Add `@json_option` to each command that supports structured output and call
|
|
|
118
118
|
`emit` once with both the data and its human renderer. `JsonAwareGroup` then
|
|
119
119
|
keeps parse failures structured when `--json` was requested.
|
|
120
120
|
|
|
121
|
+
## Upgrading
|
|
122
|
+
|
|
123
|
+
Upgrading the package is the whole workflow:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
uv tool upgrade --all
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`JsonAwareGroup` rewrites any installed copy of the skill that differs from
|
|
130
|
+
the packaged one, on the way into a command. It only ever rewrites a
|
|
131
|
+
directory that already holds this skill: putting one somewhere new is
|
|
132
|
+
`skill install`. Failures are silent, and `skill` itself is skipped so
|
|
133
|
+
`skill status` still reports drift.
|
|
134
|
+
|
|
135
|
+
Set `AGENTCLI_NO_SKILL_REFRESH=1` to switch it off. A top-level group that
|
|
136
|
+
is not `JsonAwareGroup` asks for the refresh itself:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from agentcli import refresh_skill
|
|
140
|
+
|
|
141
|
+
refresh_skill(name="acme", package="acme")
|
|
142
|
+
```
|
|
143
|
+
|
|
121
144
|
## Develop this package
|
|
122
145
|
|
|
123
146
|
```sh
|
|
@@ -160,13 +183,14 @@ The stable public surface is:
|
|
|
160
183
|
|
|
161
184
|
- `UsageError`, `RemoteError`, `AssertionFailure`, and `StrictFailure`.
|
|
162
185
|
- `dumps`, `emit`, `emit_error`, `json_option`, and `limit_option`.
|
|
163
|
-
- `JsonAwareGroup` for every consuming tool's top-level group.
|
|
186
|
+
- `JsonAwareGroup` for every consuming tool's top-level group. It also keeps
|
|
187
|
+
the installed skill in step with the packaged one.
|
|
188
|
+
- `refresh_skill(name=..., package=...)` to do that from another group.
|
|
164
189
|
- `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
|
|
165
190
|
`status`. Installation refuses an unrelated destination, recognises owned
|
|
166
191
|
broken symlinks an older version left, always copies, and supports `--to`
|
|
167
192
|
and `--dry-run`. `status` compares each installed copy against the packaged
|
|
168
|
-
one and reports `current` or `stale
|
|
169
|
-
refreshes a skill already on disk. It never links: a link points into the environment, whose
|
|
193
|
+
one and reports `current` or `stale`. It never links: a link points into the environment, whose
|
|
170
194
|
path carries the interpreter version, so a rebuild elsewhere leaves the
|
|
171
195
|
skill silently absent rather than merely stale. With no options it installs everywhere the skill is wanted
|
|
172
196
|
and refreshes its own earlier copies, so plain `install` is the whole
|
|
@@ -94,6 +94,29 @@ Add `@json_option` to each command that supports structured output and call
|
|
|
94
94
|
`emit` once with both the data and its human renderer. `JsonAwareGroup` then
|
|
95
95
|
keeps parse failures structured when `--json` was requested.
|
|
96
96
|
|
|
97
|
+
## Upgrading
|
|
98
|
+
|
|
99
|
+
Upgrading the package is the whole workflow:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
uv tool upgrade --all
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`JsonAwareGroup` rewrites any installed copy of the skill that differs from
|
|
106
|
+
the packaged one, on the way into a command. It only ever rewrites a
|
|
107
|
+
directory that already holds this skill: putting one somewhere new is
|
|
108
|
+
`skill install`. Failures are silent, and `skill` itself is skipped so
|
|
109
|
+
`skill status` still reports drift.
|
|
110
|
+
|
|
111
|
+
Set `AGENTCLI_NO_SKILL_REFRESH=1` to switch it off. A top-level group that
|
|
112
|
+
is not `JsonAwareGroup` asks for the refresh itself:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from agentcli import refresh_skill
|
|
116
|
+
|
|
117
|
+
refresh_skill(name="acme", package="acme")
|
|
118
|
+
```
|
|
119
|
+
|
|
97
120
|
## Develop this package
|
|
98
121
|
|
|
99
122
|
```sh
|
|
@@ -136,13 +159,14 @@ The stable public surface is:
|
|
|
136
159
|
|
|
137
160
|
- `UsageError`, `RemoteError`, `AssertionFailure`, and `StrictFailure`.
|
|
138
161
|
- `dumps`, `emit`, `emit_error`, `json_option`, and `limit_option`.
|
|
139
|
-
- `JsonAwareGroup` for every consuming tool's top-level group.
|
|
162
|
+
- `JsonAwareGroup` for every consuming tool's top-level group. It also keeps
|
|
163
|
+
the installed skill in step with the packaged one.
|
|
164
|
+
- `refresh_skill(name=..., package=...)` to do that from another group.
|
|
140
165
|
- `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
|
|
141
166
|
`status`. Installation refuses an unrelated destination, recognises owned
|
|
142
167
|
broken symlinks an older version left, always copies, and supports `--to`
|
|
143
168
|
and `--dry-run`. `status` compares each installed copy against the packaged
|
|
144
|
-
one and reports `current` or `stale
|
|
145
|
-
refreshes a skill already on disk. It never links: a link points into the environment, whose
|
|
169
|
+
one and reports `current` or `stale`. It never links: a link points into the environment, whose
|
|
146
170
|
path carries the interpreter version, so a rebuild elsewhere leaves the
|
|
147
171
|
skill silently absent rather than merely stale. With no options it installs everywhere the skill is wanted
|
|
148
172
|
and refreshes its own earlier copies, so plain `install` is the whole
|
|
@@ -10,7 +10,7 @@ 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.5.0"
|
|
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
16
|
requires-python = ">=3.12"
|
|
@@ -17,13 +17,14 @@ from agentcli.exits import (
|
|
|
17
17
|
from agentcli.group import JsonAwareGroup
|
|
18
18
|
from agentcli.guide import guide_command
|
|
19
19
|
from agentcli.output import dumps, emit, emit_error, json_option, limit_option
|
|
20
|
-
from agentcli.skill import skill_group
|
|
20
|
+
from agentcli.skill import SkillGroup, refresh_skill, skill_group
|
|
21
21
|
|
|
22
22
|
__all__ = [
|
|
23
23
|
"KINDS",
|
|
24
24
|
"AssertionFailure",
|
|
25
25
|
"JsonAwareGroup",
|
|
26
26
|
"RemoteError",
|
|
27
|
+
"SkillGroup",
|
|
27
28
|
"StrictFailure",
|
|
28
29
|
"UsageError",
|
|
29
30
|
"candidate",
|
|
@@ -36,6 +37,7 @@ __all__ = [
|
|
|
36
37
|
"macro_options",
|
|
37
38
|
"matches",
|
|
38
39
|
"rank",
|
|
40
|
+
"refresh_skill",
|
|
39
41
|
"skill_group",
|
|
40
42
|
"unverifiable",
|
|
41
43
|
]
|
|
@@ -10,6 +10,7 @@ problem, and a tool that solves it locally is a tool the next one forgets to
|
|
|
10
10
|
copy.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
import os
|
|
13
14
|
import sys
|
|
14
15
|
from collections.abc import Sequence
|
|
15
16
|
from typing import Any, Literal, NoReturn, overload
|
|
@@ -17,6 +18,10 @@ from typing import Any, Literal, NoReturn, overload
|
|
|
17
18
|
import click
|
|
18
19
|
|
|
19
20
|
from agentcli.output import emit_error
|
|
21
|
+
from agentcli.skill import SkillGroup
|
|
22
|
+
|
|
23
|
+
# Set to any non-empty value to keep a run from touching the skills on disk.
|
|
24
|
+
NO_REFRESH_ENV = "AGENTCLI_NO_SKILL_REFRESH"
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
class JsonAwareGroup(click.Group):
|
|
@@ -59,6 +64,13 @@ class JsonAwareGroup(click.Group):
|
|
|
59
64
|
arguments = list(sys.argv[1:] if args is None else args)
|
|
60
65
|
self._json_requested = "--json" in arguments
|
|
61
66
|
|
|
67
|
+
# Only when this process *is* the tool's command line, which is what
|
|
68
|
+
# reading `sys.argv` means. A caller passing its own arguments -- an
|
|
69
|
+
# embedding, or a consumer's `CliRunner` test -- gets no writes under
|
|
70
|
+
# `~` it never asked for.
|
|
71
|
+
if args is None:
|
|
72
|
+
self._refresh_skill(arguments)
|
|
73
|
+
|
|
62
74
|
return super().main(
|
|
63
75
|
arguments,
|
|
64
76
|
prog_name,
|
|
@@ -67,6 +79,28 @@ class JsonAwareGroup(click.Group):
|
|
|
67
79
|
**extra,
|
|
68
80
|
)
|
|
69
81
|
|
|
82
|
+
def _refresh_skill(self, arguments: Sequence[str]) -> None:
|
|
83
|
+
"""Bring already-installed copies of this tool's skill up to date.
|
|
84
|
+
|
|
85
|
+
Upgrading a package never touched a skill already on disk, so the copy
|
|
86
|
+
an agent read could sit releases behind the binary it documents, and
|
|
87
|
+
nothing said so. Doing it here makes `uv tool upgrade` the whole
|
|
88
|
+
workflow: no second command per tool to remember.
|
|
89
|
+
|
|
90
|
+
Not done for `skill` itself. That group is where drift is reported and
|
|
91
|
+
acted on, and a `skill status` that silently repaired what it was
|
|
92
|
+
about to describe would have nothing left to report.
|
|
93
|
+
"""
|
|
94
|
+
if os.environ.get(NO_REFRESH_ENV):
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if "skill" in arguments:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
skill = self.commands.get("skill")
|
|
101
|
+
if isinstance(skill, SkillGroup):
|
|
102
|
+
skill.refresh()
|
|
103
|
+
|
|
70
104
|
def _drift_hint(self, ctx: click.Context, exc: Exception) -> str:
|
|
71
105
|
"""Why a name this caller expected might not exist.
|
|
72
106
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""A bad flag must still honour `--json`, before click has parsed it."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import pytest
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from agentcli import JsonAwareGroup, UsageError, emit, skill_group
|
|
12
|
+
from agentcli.group import NO_REFRESH_ENV
|
|
13
|
+
from agentcli.skill import SHARED_DIR
|
|
14
|
+
|
|
15
|
+
MANIFEST = "---\nname: faketool\ndescription: router\n---\nbody\n"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def throwaway_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
20
|
+
"""Every run refreshes the skills under `~`, so no test may see a real one."""
|
|
21
|
+
home = tmp_path / "home"
|
|
22
|
+
home.mkdir()
|
|
23
|
+
monkeypatch.setattr(Path, "home", lambda: home)
|
|
24
|
+
return home
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group(cls=JsonAwareGroup)
|
|
28
|
+
def cli() -> None:
|
|
29
|
+
"""Fixture tool."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.command("go")
|
|
33
|
+
@click.option("--limit", type=click.IntRange(min=0), default=10)
|
|
34
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
35
|
+
@click.option("--boom", is_flag=True)
|
|
36
|
+
def go(limit: int, json_output: bool, boom: bool) -> None:
|
|
37
|
+
if boom:
|
|
38
|
+
raise UsageError("refused on purpose")
|
|
39
|
+
emit({"limit": limit}, json_output=json_output, human=lambda d: ["ok"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
cli.add_command(go)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run(*args: str):
|
|
46
|
+
return CliRunner().invoke(cli, list(args))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_success_is_enveloped() -> None:
|
|
50
|
+
result = _run("go", "--json")
|
|
51
|
+
|
|
52
|
+
assert result.exit_code == 0
|
|
53
|
+
assert json.loads(result.output) == {"ok": True, "data": {"limit": 10}}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_raised_failure_is_enveloped() -> None:
|
|
57
|
+
result = _run("go", "--json", "--boom")
|
|
58
|
+
|
|
59
|
+
assert result.exit_code == 1
|
|
60
|
+
assert json.loads(result.output) == {
|
|
61
|
+
"ok": False,
|
|
62
|
+
"error": {"message": "refused on purpose"},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_parse_failure_is_enveloped_though_json_never_parsed() -> None:
|
|
67
|
+
"""The hard case: click refuses before the subcommand sees --json."""
|
|
68
|
+
result = _run("go", "--json", "--limit", "-1")
|
|
69
|
+
|
|
70
|
+
assert result.exit_code == 1
|
|
71
|
+
payload = json.loads(result.output)
|
|
72
|
+
assert payload["ok"] is False
|
|
73
|
+
assert "-1" in payload["error"]["message"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_unknown_option_is_enveloped() -> None:
|
|
77
|
+
result = _run("go", "--json", "--nonexistent")
|
|
78
|
+
|
|
79
|
+
assert result.exit_code == 1
|
|
80
|
+
assert json.loads(result.output)["ok"] is False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_human_path_keeps_clicks_own_usage_block() -> None:
|
|
84
|
+
"""Without --json a person gets click's output, not a uniform shape."""
|
|
85
|
+
result = _run("go", "--limit", "-1")
|
|
86
|
+
|
|
87
|
+
assert result.exit_code == 1
|
|
88
|
+
assert "Usage:" in result.output
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_main_accepts_clicks_own_positional_arguments(
|
|
92
|
+
capsys: pytest.CaptureFixture[str],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""A caller holding a `click.Group` may pass click's own positionals."""
|
|
95
|
+
group: click.Group = cli
|
|
96
|
+
|
|
97
|
+
group.main(["go", "--json"], "tool", None, False)
|
|
98
|
+
|
|
99
|
+
assert json.loads(capsys.readouterr().out) == {
|
|
100
|
+
"ok": True,
|
|
101
|
+
"data": {"limit": 10},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@click.group(cls=JsonAwareGroup)
|
|
106
|
+
def skilled() -> None:
|
|
107
|
+
"""A tool that ships a skill."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
skilled.add_command(go)
|
|
111
|
+
skilled.add_command(skill_group(name="faketool", package="agentcli"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
("args", "hinted"),
|
|
116
|
+
[
|
|
117
|
+
(["go", "--nope"], True),
|
|
118
|
+
(["nosuchcommand"], True),
|
|
119
|
+
(["go", "--limit"], False),
|
|
120
|
+
(["go", "--limit", "-1"], False),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
def test_an_unknown_name_suggests_a_stale_skill(args, hinted: bool) -> None:
|
|
124
|
+
"""A caller reading a skill older than the binary asks for a name the
|
|
125
|
+
binary dropped. No other failure looks like that, so nothing else hints."""
|
|
126
|
+
result = CliRunner().invoke(skilled, args)
|
|
127
|
+
|
|
128
|
+
assert result.exit_code != 0
|
|
129
|
+
assert ("skill install" in result.output) is hinted
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_a_tool_without_a_skill_suggests_nothing() -> None:
|
|
133
|
+
result = CliRunner().invoke(cli, ["nosuchcommand"])
|
|
134
|
+
|
|
135
|
+
assert result.exit_code != 0
|
|
136
|
+
assert "skill install" not in result.output
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _install_stale(home: Path) -> Path:
|
|
140
|
+
"""A skill on disk from before the manifest this package now ships."""
|
|
141
|
+
target = home / SHARED_DIR / "faketool"
|
|
142
|
+
target.mkdir(parents=True)
|
|
143
|
+
(target / "SKILL.md").write_text(MANIFEST)
|
|
144
|
+
return target / "SKILL.md"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.fixture
|
|
148
|
+
def shipped(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
149
|
+
"""The manifest `skilled` would install, as a newer release ships it."""
|
|
150
|
+
source = tmp_path / "SKILL.md"
|
|
151
|
+
source.write_text(MANIFEST + "a section added since\n")
|
|
152
|
+
monkeypatch.setattr(
|
|
153
|
+
"agentcli.skill.packaged_skill", lambda **kwargs: source
|
|
154
|
+
)
|
|
155
|
+
return source
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _as_the_command_line(
|
|
159
|
+
group: click.Group, argv: list[str], monkeypatch: pytest.MonkeyPatch
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Run `group` the way its console script does: from `sys.argv`.
|
|
162
|
+
|
|
163
|
+
The refresh happens only when the process *is* the tool's command line,
|
|
164
|
+
so a `CliRunner` -- which always hands click an argument list -- cannot
|
|
165
|
+
reach it, and neither can a consumer's test suite.
|
|
166
|
+
"""
|
|
167
|
+
monkeypatch.setattr(sys, "argv", ["tool", *argv])
|
|
168
|
+
group.main(standalone_mode=False)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_running_any_command_refreshes_a_stale_skill(
|
|
172
|
+
throwaway_home: Path, shipped: Path, monkeypatch: pytest.MonkeyPatch
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Upgrading the package is the whole workflow: no second command."""
|
|
175
|
+
installed = _install_stale(throwaway_home)
|
|
176
|
+
|
|
177
|
+
_as_the_command_line(skilled, ["go"], monkeypatch)
|
|
178
|
+
|
|
179
|
+
assert installed.read_text() == shipped.read_text()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_a_passed_argument_list_touches_no_skills(
|
|
183
|
+
throwaway_home: Path, shipped: Path
|
|
184
|
+
) -> None:
|
|
185
|
+
"""A consumer's own `CliRunner` tests must not rewrite the skills under
|
|
186
|
+
the developer's real home."""
|
|
187
|
+
installed = _install_stale(throwaway_home)
|
|
188
|
+
|
|
189
|
+
result = CliRunner().invoke(skilled, ["go"])
|
|
190
|
+
|
|
191
|
+
assert result.exit_code == 0
|
|
192
|
+
assert installed.read_text() == MANIFEST
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_the_skill_command_reports_drift_rather_than_hiding_it(
|
|
196
|
+
throwaway_home: Path,
|
|
197
|
+
shipped: Path,
|
|
198
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
199
|
+
capsys: pytest.CaptureFixture[str],
|
|
200
|
+
) -> None:
|
|
201
|
+
"""`skill status` describing a copy it had just silently repaired would
|
|
202
|
+
have nothing left to report."""
|
|
203
|
+
installed = _install_stale(throwaway_home)
|
|
204
|
+
|
|
205
|
+
_as_the_command_line(skilled, ["skill", "status"], monkeypatch)
|
|
206
|
+
|
|
207
|
+
assert "stale" in capsys.readouterr().out
|
|
208
|
+
assert installed.read_text() == MANIFEST
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_refresh_can_be_switched_off(
|
|
212
|
+
throwaway_home: Path, shipped: Path, monkeypatch: pytest.MonkeyPatch
|
|
213
|
+
) -> None:
|
|
214
|
+
monkeypatch.setenv(NO_REFRESH_ENV, "1")
|
|
215
|
+
installed = _install_stale(throwaway_home)
|
|
216
|
+
|
|
217
|
+
_as_the_command_line(skilled, ["go"], monkeypatch)
|
|
218
|
+
|
|
219
|
+
assert installed.read_text() == MANIFEST
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_a_tool_without_a_skill_group_still_runs(
|
|
223
|
+
throwaway_home: Path,
|
|
224
|
+
shipped: Path,
|
|
225
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
226
|
+
capsys: pytest.CaptureFixture[str],
|
|
227
|
+
) -> None:
|
|
228
|
+
"""The refresh is opportunistic; nothing to refresh is not an error."""
|
|
229
|
+
_as_the_command_line(cli, ["go", "--json"], monkeypatch)
|
|
230
|
+
|
|
231
|
+
assert json.loads(capsys.readouterr().out)["ok"] is True
|
|
@@ -8,10 +8,15 @@ directory it can see, with the command to cover those too.
|
|
|
8
8
|
|
|
9
9
|
Parameterised by skill name and package because the two hand-written copies
|
|
10
10
|
this replaces had already drifted apart in exactly the guards that matter.
|
|
11
|
+
|
|
12
|
+
`refresh_skill` closes the gap that made `install` a chore: upgrading a
|
|
13
|
+
package never touched a skill already on disk, so the copy an agent reads
|
|
14
|
+
could sit releases behind the binary it documents.
|
|
11
15
|
"""
|
|
12
16
|
|
|
13
17
|
import shutil
|
|
14
|
-
from collections.abc import Iterable
|
|
18
|
+
from collections.abc import Callable, Iterable
|
|
19
|
+
from functools import partial
|
|
15
20
|
from importlib import resources
|
|
16
21
|
from pathlib import Path
|
|
17
22
|
from typing import Any
|
|
@@ -73,6 +78,19 @@ def packaged_skill(*, name: str, package: str) -> Path:
|
|
|
73
78
|
raise click.ClickException(f"SKILL.md not found; looked in {looked}")
|
|
74
79
|
|
|
75
80
|
|
|
81
|
+
def _known_locations(home: Path, name: str) -> list[tuple[str, Path]]:
|
|
82
|
+
"""Every location this tool manages, shared first, present or not.
|
|
83
|
+
|
|
84
|
+
One list, because a location missing from one of `status`, `uninstall`,
|
|
85
|
+
or `refresh_skill` is a copy that is reported but never cleaned, or
|
|
86
|
+
cleaned but never refreshed.
|
|
87
|
+
"""
|
|
88
|
+
return [("Shared (.agents)", home / SHARED_DIR / name)] + [
|
|
89
|
+
(label, home / skills / name)
|
|
90
|
+
for label, (_, skills) in TOOL_DIRS.items()
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
76
94
|
def detected_tools(home: Path) -> dict[str, Path]:
|
|
77
95
|
"""Return skills directories for the agent tools present on this machine."""
|
|
78
96
|
return {
|
|
@@ -157,9 +175,8 @@ def _place(source: Path, target: Path, *, name: str) -> str:
|
|
|
157
175
|
Always a copy. A link points into the environment this CLI was installed
|
|
158
176
|
into, and that path is not stable: it carries the interpreter version, so
|
|
159
177
|
an environment rebuilt on another Python leaves a dangling link and the
|
|
160
|
-
skill silently disappears.
|
|
161
|
-
|
|
162
|
-
and the manual it routes to (`<tool> guide`) ships in the binary.
|
|
178
|
+
skill silently disappears. What a copy used to cost -- a skill left stale
|
|
179
|
+
by an upgrade -- `refresh_skill` now pays back on the next run.
|
|
163
180
|
"""
|
|
164
181
|
refusal = _refusal(target, name=name)
|
|
165
182
|
if refusal is not None:
|
|
@@ -200,11 +217,7 @@ def _status_rows(
|
|
|
200
217
|
home: Path, name: str, source: Path | None = None
|
|
201
218
|
) -> list[dict[str, Any]]:
|
|
202
219
|
"""One row per known location, shared first, whether present or not."""
|
|
203
|
-
locations =
|
|
204
|
-
locations += [
|
|
205
|
-
(label, home / skills / name)
|
|
206
|
-
for label, (_, skills) in TOOL_DIRS.items()
|
|
207
|
-
]
|
|
220
|
+
locations = _known_locations(home, name)
|
|
208
221
|
|
|
209
222
|
rows = []
|
|
210
223
|
for label, path in locations:
|
|
@@ -233,7 +246,60 @@ def _status_lines(payload: dict[str, Any]) -> Iterable[str]:
|
|
|
233
246
|
)
|
|
234
247
|
|
|
235
248
|
|
|
236
|
-
def
|
|
249
|
+
def refresh_skill(
|
|
250
|
+
*, name: str, package: str, home: Path | None = None
|
|
251
|
+
) -> list[Path]:
|
|
252
|
+
"""Recopy every installed copy of this skill the package has moved past.
|
|
253
|
+
|
|
254
|
+
Installing a package never refreshed a skill already on disk, so an agent
|
|
255
|
+
could read a manifest several releases behind the binary it documents,
|
|
256
|
+
with nothing to say so. Running this on the way into a command makes the
|
|
257
|
+
upgrade the whole workflow.
|
|
258
|
+
|
|
259
|
+
Only ever touches a directory that already holds *this* skill. Putting a
|
|
260
|
+
skill somewhere new stays an explicit `skill install`, so this can never
|
|
261
|
+
resurrect a location the user deliberately cleared.
|
|
262
|
+
|
|
263
|
+
Best effort throughout: a package whose manifest cannot be found, or a
|
|
264
|
+
skills directory that cannot be written, is not a reason to fail the
|
|
265
|
+
command the caller actually asked for.
|
|
266
|
+
"""
|
|
267
|
+
try:
|
|
268
|
+
source = packaged_skill(name=name, package=package)
|
|
269
|
+
except click.ClickException:
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
refreshed = []
|
|
273
|
+
for _, target in _known_locations(home or Path.home(), name):
|
|
274
|
+
# `stale` is reached only for a directory holding our own skill whose
|
|
275
|
+
# bytes differ, which is exactly the set worth rewriting.
|
|
276
|
+
if _state(target, source, name=name) != "stale":
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
shutil.copy2(source, target / "SKILL.md")
|
|
281
|
+
except OSError:
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
refreshed.append(target)
|
|
285
|
+
|
|
286
|
+
return refreshed
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class SkillGroup(click.Group):
|
|
290
|
+
"""The `skill` group, carrying the refresh its root group runs.
|
|
291
|
+
|
|
292
|
+
The refresh happens on the way into every *other* command, and the root
|
|
293
|
+
group that runs it there knows neither the skill name nor the package.
|
|
294
|
+
Carrying the bound call here is how it reaches that code without every
|
|
295
|
+
consumer wiring it up by hand -- which is the mistake this module exists
|
|
296
|
+
to stop repeating.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
refresh: Callable[[], list[Path]]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def skill_group(*, name: str, package: str) -> SkillGroup:
|
|
237
303
|
"""Build the `skill` command group for one tool.
|
|
238
304
|
|
|
239
305
|
`name` is both the skill name and the binary that carries it, so it also
|
|
@@ -245,7 +311,7 @@ def skill_group(*, name: str, package: str) -> click.Group:
|
|
|
245
311
|
)
|
|
246
312
|
dry_run_help = "Print what would happen without touching the filesystem."
|
|
247
313
|
|
|
248
|
-
@click.group("skill")
|
|
314
|
+
@click.group("skill", cls=SkillGroup)
|
|
249
315
|
def skill() -> None:
|
|
250
316
|
"""Install the packaged Agent Skill so agents can discover this tool."""
|
|
251
317
|
|
|
@@ -339,9 +405,7 @@ def skill_group(*, name: str, package: str) -> click.Group:
|
|
|
339
405
|
# present: an uninstalled tool can leave a skill behind, and that is
|
|
340
406
|
# exactly what needs removing. Missing ones are skipped quietly.
|
|
341
407
|
if destination is None:
|
|
342
|
-
targets += [
|
|
343
|
-
home / skills / name for _, skills in TOOL_DIRS.values()
|
|
344
|
-
]
|
|
408
|
+
targets += [path for _, path in _known_locations(home, name)]
|
|
345
409
|
|
|
346
410
|
removed = 0
|
|
347
411
|
for target in dict.fromkeys(targets):
|
|
@@ -384,4 +448,6 @@ def skill_group(*, name: str, package: str) -> click.Group:
|
|
|
384
448
|
}
|
|
385
449
|
emit(payload, json_output=json_output, human=_status_lines)
|
|
386
450
|
|
|
451
|
+
skill.refresh = partial(refresh_skill, name=name, package=package)
|
|
452
|
+
|
|
387
453
|
return skill
|
|
@@ -18,8 +18,10 @@ from click.testing import CliRunner
|
|
|
18
18
|
|
|
19
19
|
from agentcli.skill import (
|
|
20
20
|
SHARED_DIR,
|
|
21
|
+
SkillGroup,
|
|
21
22
|
detected_tools,
|
|
22
23
|
packaged_skill,
|
|
24
|
+
refresh_skill,
|
|
23
25
|
skill_group,
|
|
24
26
|
)
|
|
25
27
|
|
|
@@ -395,3 +397,89 @@ def test_status_still_answers_for_an_absent_location(tool: Tool) -> None:
|
|
|
395
397
|
rows = json.loads(result.output)["data"]["locations"]
|
|
396
398
|
assert all(row["state"] == "absent" for row in rows)
|
|
397
399
|
assert all(row["installed"] is False for row in rows)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_refresh_updates_a_stale_copy(tool: Tool) -> None:
|
|
403
|
+
"""The whole point: an upgraded package leaves no stale skill behind."""
|
|
404
|
+
tool.run("install")
|
|
405
|
+
tool.source.write_text(MANIFEST + "a section added since\n")
|
|
406
|
+
|
|
407
|
+
refreshed = refresh_skill(name=NAME, package=NAME, home=tool.home)
|
|
408
|
+
|
|
409
|
+
assert refreshed == [tool.shared()]
|
|
410
|
+
assert (tool.shared() / "SKILL.md").read_text() == tool.source.read_text()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_refresh_leaves_a_current_copy_alone(tool: Tool) -> None:
|
|
414
|
+
tool.run("install")
|
|
415
|
+
|
|
416
|
+
assert refresh_skill(name=NAME, package=NAME, home=tool.home) == []
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_refresh_never_creates_a_location(tool: Tool) -> None:
|
|
420
|
+
"""Placing a skill somewhere new stays an explicit `skill install`, so an
|
|
421
|
+
uninstall cannot be undone by the next command that happens to run."""
|
|
422
|
+
(tool.home / ".claude").mkdir()
|
|
423
|
+
|
|
424
|
+
assert refresh_skill(name=NAME, package=NAME, home=tool.home) == []
|
|
425
|
+
assert not tool.shared().exists()
|
|
426
|
+
assert not (tool.home / ".claude" / "skills").exists()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_refresh_leaves_a_foreign_directory_alone(tool: Tool) -> None:
|
|
430
|
+
"""The install guard has to hold for the unattended path too."""
|
|
431
|
+
target = tool.home / SHARED_DIR / NAME
|
|
432
|
+
manifest = _install_foreign(target)
|
|
433
|
+
|
|
434
|
+
assert refresh_skill(name=NAME, package=NAME, home=tool.home) == []
|
|
435
|
+
assert manifest.read_text() == FOREIGN
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def test_refresh_covers_every_known_location(tool: Tool) -> None:
|
|
439
|
+
"""Including a tool since uninstalled: its copy is still one agents read."""
|
|
440
|
+
(tool.home / ".claude").mkdir()
|
|
441
|
+
tool.run("install")
|
|
442
|
+
stale = tool.home / ".cursor" / "skills" / NAME
|
|
443
|
+
stale.mkdir(parents=True)
|
|
444
|
+
(stale / "SKILL.md").write_text(MANIFEST)
|
|
445
|
+
tool.source.write_text(MANIFEST + "a section added since\n")
|
|
446
|
+
|
|
447
|
+
refreshed = refresh_skill(name=NAME, package=NAME, home=tool.home)
|
|
448
|
+
|
|
449
|
+
assert stale in refreshed
|
|
450
|
+
assert (tool.home / ".claude" / "skills" / NAME) in refreshed
|
|
451
|
+
for target in refreshed:
|
|
452
|
+
assert (target / "SKILL.md").read_text() == tool.source.read_text()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_refresh_survives_an_unwritable_directory(
|
|
456
|
+
tool: Tool, monkeypatch: pytest.MonkeyPatch
|
|
457
|
+
) -> None:
|
|
458
|
+
"""A skills directory the user has locked down is their arrangement, not
|
|
459
|
+
a reason to fail the command they actually ran."""
|
|
460
|
+
tool.run("install")
|
|
461
|
+
tool.source.write_text(MANIFEST + "a section added since\n")
|
|
462
|
+
|
|
463
|
+
def refuse(source, target):
|
|
464
|
+
raise OSError(13, "Permission denied")
|
|
465
|
+
|
|
466
|
+
monkeypatch.setattr(shutil, "copy2", refuse)
|
|
467
|
+
|
|
468
|
+
assert refresh_skill(name=NAME, package=NAME, home=tool.home) == []
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def test_refresh_survives_a_missing_manifest(tool: Tool) -> None:
|
|
472
|
+
"""Nothing to copy from is nothing to do, not a traceback."""
|
|
473
|
+
tool.run("install")
|
|
474
|
+
tool.source.unlink()
|
|
475
|
+
|
|
476
|
+
assert refresh_skill(name=NAME, package=NAME, home=tool.home) == []
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_skill_group_carries_its_bound_refresh(tool: Tool) -> None:
|
|
480
|
+
"""How the root group reaches a refresh it knows no name or package for."""
|
|
481
|
+
tool.run("install")
|
|
482
|
+
tool.source.write_text(MANIFEST + "a section added since\n")
|
|
483
|
+
|
|
484
|
+
assert isinstance(tool.cli, SkillGroup)
|
|
485
|
+
assert tool.cli.refresh() == [tool.shared()]
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
"""A bad flag must still honour `--json`, before click has parsed it."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
|
|
5
|
-
import click
|
|
6
|
-
import pytest
|
|
7
|
-
from click.testing import CliRunner
|
|
8
|
-
|
|
9
|
-
from agentcli import JsonAwareGroup, UsageError, emit, skill_group
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@click.group(cls=JsonAwareGroup)
|
|
13
|
-
def cli() -> None:
|
|
14
|
-
"""Fixture tool."""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@click.command("go")
|
|
18
|
-
@click.option("--limit", type=click.IntRange(min=0), default=10)
|
|
19
|
-
@click.option("--json", "json_output", is_flag=True)
|
|
20
|
-
@click.option("--boom", is_flag=True)
|
|
21
|
-
def go(limit: int, json_output: bool, boom: bool) -> None:
|
|
22
|
-
if boom:
|
|
23
|
-
raise UsageError("refused on purpose")
|
|
24
|
-
emit({"limit": limit}, json_output=json_output, human=lambda d: ["ok"])
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
cli.add_command(go)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _run(*args: str):
|
|
31
|
-
return CliRunner().invoke(cli, list(args))
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_success_is_enveloped() -> None:
|
|
35
|
-
result = _run("go", "--json")
|
|
36
|
-
|
|
37
|
-
assert result.exit_code == 0
|
|
38
|
-
assert json.loads(result.output) == {"ok": True, "data": {"limit": 10}}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_raised_failure_is_enveloped() -> None:
|
|
42
|
-
result = _run("go", "--json", "--boom")
|
|
43
|
-
|
|
44
|
-
assert result.exit_code == 1
|
|
45
|
-
assert json.loads(result.output) == {
|
|
46
|
-
"ok": False,
|
|
47
|
-
"error": {"message": "refused on purpose"},
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_parse_failure_is_enveloped_though_json_never_parsed() -> None:
|
|
52
|
-
"""The hard case: click refuses before the subcommand sees --json."""
|
|
53
|
-
result = _run("go", "--json", "--limit", "-1")
|
|
54
|
-
|
|
55
|
-
assert result.exit_code == 1
|
|
56
|
-
payload = json.loads(result.output)
|
|
57
|
-
assert payload["ok"] is False
|
|
58
|
-
assert "-1" in payload["error"]["message"]
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_unknown_option_is_enveloped() -> None:
|
|
62
|
-
result = _run("go", "--json", "--nonexistent")
|
|
63
|
-
|
|
64
|
-
assert result.exit_code == 1
|
|
65
|
-
assert json.loads(result.output)["ok"] is False
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def test_human_path_keeps_clicks_own_usage_block() -> None:
|
|
69
|
-
"""Without --json a person gets click's output, not a uniform shape."""
|
|
70
|
-
result = _run("go", "--limit", "-1")
|
|
71
|
-
|
|
72
|
-
assert result.exit_code == 1
|
|
73
|
-
assert "Usage:" in result.output
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_main_accepts_clicks_own_positional_arguments(
|
|
77
|
-
capsys: pytest.CaptureFixture[str],
|
|
78
|
-
) -> None:
|
|
79
|
-
"""A caller holding a `click.Group` may pass click's own positionals."""
|
|
80
|
-
group: click.Group = cli
|
|
81
|
-
|
|
82
|
-
group.main(["go", "--json"], "tool", None, False)
|
|
83
|
-
|
|
84
|
-
assert json.loads(capsys.readouterr().out) == {
|
|
85
|
-
"ok": True,
|
|
86
|
-
"data": {"limit": 10},
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|