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.
Files changed (22) hide show
  1. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/PKG-INFO +28 -4
  2. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/README.md +27 -3
  3. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/pyproject.toml +1 -1
  4. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/__init__.py +3 -1
  5. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/group.py +34 -0
  6. click_agentcli-0.5.0/src/agentcli/group_test.py +231 -0
  7. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/skill.py +80 -14
  8. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/skill_test.py +88 -0
  9. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/uv.lock +1 -1
  10. click_agentcli-0.4.1/src/agentcli/group_test.py +0 -121
  11. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/.github/workflows/ci.yml +0 -0
  12. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/.github/workflows/release.yml +0 -0
  13. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/.gitignore +0 -0
  14. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/LICENSE +0 -0
  15. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/candidates.py +0 -0
  16. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/candidates_test.py +0 -0
  17. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/exits.py +0 -0
  18. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/exits_test.py +0 -0
  19. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/guide.py +0 -0
  20. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/guide_test.py +0 -0
  21. {click_agentcli-0.4.1 → click_agentcli-0.5.0}/src/agentcli/output.py +0 -0
  22. {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.4.1
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`, because upgrading a package never
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`, because upgrading a package never
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.4.1"
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. Copying costs a stale skill after an upgrade,
161
- which is the louder failure and the cheaper one -- the skill is a router,
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 = [("Shared (.agents)", home / SHARED_DIR / name)]
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 skill_group(*, name: str, package: str) -> click.Group:
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()]
@@ -16,7 +16,7 @@ wheels = [
16
16
 
17
17
  [[package]]
18
18
  name = "click-agentcli"
19
- version = "0.4.1"
19
+ version = "0.5.0"
20
20
  source = { editable = "." }
21
21
  dependencies = [
22
22
  { name = "click" },
@@ -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