click-agentcli 0.3.0__tar.gz → 0.4.1__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 (21) hide show
  1. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/PKG-INFO +17 -9
  2. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/README.md +16 -8
  3. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/pyproject.toml +1 -1
  4. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/__init__.py +0 -2
  5. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/candidates.py +19 -16
  6. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/candidates_test.py +59 -4
  7. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/group.py +31 -1
  8. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/group_test.py +35 -1
  9. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/skill.py +63 -49
  10. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/skill_test.py +40 -26
  11. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/uv.lock +1 -1
  12. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/.github/workflows/ci.yml +0 -0
  13. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/.github/workflows/release.yml +0 -0
  14. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/.gitignore +0 -0
  15. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/LICENSE +0 -0
  16. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/exits.py +0 -0
  17. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/exits_test.py +0 -0
  18. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/guide.py +0 -0
  19. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/guide_test.py +0 -0
  20. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/src/agentcli/output.py +0 -0
  21. {click_agentcli-0.3.0 → click_agentcli-0.4.1}/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.0
3
+ Version: 0.4.1
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
@@ -163,8 +163,12 @@ The stable public surface is:
163
163
  - `JsonAwareGroup` for every consuming tool's top-level group.
164
164
  - `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
165
165
  `status`. Installation refuses an unrelated destination, recognises owned
166
- broken symlinks, copies by default, and supports `--link`, `--to`, and
167
- `--dry-run`. With no options it installs everywhere the skill is wanted
166
+ broken symlinks an older version left, always copies, and supports `--to`
167
+ 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
170
+ path carries the interpreter version, so a rebuild elsewhere leaves the
171
+ skill silently absent rather than merely stale. With no options it installs everywhere the skill is wanted
168
172
  and refreshes its own earlier copies, so plain `install` is the whole
169
173
  job; a directory holding somebody else's skill is still refused.
170
174
  - `guide_command(text)` for a complete manual available without a network.
@@ -174,7 +178,7 @@ The stable public surface is:
174
178
  ## Candidate contract
175
179
 
176
180
  Candidate sources answer the same question: filter things someone could eat by
177
- per-serving macros, then rank them with provenance. Recipes and restaurant
181
+ per-serving figures, then rank them with provenance. Recipes and restaurant
178
182
  meals therefore emit the same record:
179
183
 
180
184
  ```json
@@ -196,11 +200,15 @@ Sources accept `macro_options` (`--max-kcal`, `--min-protein`) and use `rank`.
196
200
  The rank key is unrounded protein per 100 kcal, then absolute protein, then
197
201
  name. `--max-kcal 0` is valid because zero-calorie records exist.
198
202
 
199
- `per_serving` contains only macros actually known by the source. Missing is
200
- never filled with zero. `complete` exposes whether the full shape is present;
201
- a candidate missing a requested filter macro is excluded and returned in the
202
- source's `unverifiable` or equivalent bucket. Every source emits that bucket,
203
- even when its loader makes it structurally empty.
203
+ `per_serving` contains only figures actually known by the source, whatever
204
+ they are a source that also publishes fibre or sodium puts them here.
205
+ Missing is never filled with zero. `candidate` takes the keys that count as a
206
+ full set from its caller as `required`, because this package does not know what
207
+ a macro is; callers answering the same question share that tuple so `complete`
208
+ keeps meaning the same thing across them. A candidate missing a requested
209
+ filter figure is excluded and returned in the source's `unverifiable` or
210
+ equivalent bucket. Every source emits that bucket, even when its loader makes
211
+ it structurally empty.
204
212
 
205
213
  This contract is the reason the tools can be independent packages: an
206
214
  orchestrator can merge and rank results without knowing which source answered.
@@ -139,8 +139,12 @@ The stable public surface is:
139
139
  - `JsonAwareGroup` for every consuming tool's top-level group.
140
140
  - `skill_group(name=..., package=...)` for `skill install`, `uninstall`, and
141
141
  `status`. Installation refuses an unrelated destination, recognises owned
142
- broken symlinks, copies by default, and supports `--link`, `--to`, and
143
- `--dry-run`. With no options it installs everywhere the skill is wanted
142
+ broken symlinks an older version left, always copies, and supports `--to`
143
+ 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
146
+ path carries the interpreter version, so a rebuild elsewhere leaves the
147
+ skill silently absent rather than merely stale. With no options it installs everywhere the skill is wanted
144
148
  and refreshes its own earlier copies, so plain `install` is the whole
145
149
  job; a directory holding somebody else's skill is still refused.
146
150
  - `guide_command(text)` for a complete manual available without a network.
@@ -150,7 +154,7 @@ The stable public surface is:
150
154
  ## Candidate contract
151
155
 
152
156
  Candidate sources answer the same question: filter things someone could eat by
153
- per-serving macros, then rank them with provenance. Recipes and restaurant
157
+ per-serving figures, then rank them with provenance. Recipes and restaurant
154
158
  meals therefore emit the same record:
155
159
 
156
160
  ```json
@@ -172,11 +176,15 @@ Sources accept `macro_options` (`--max-kcal`, `--min-protein`) and use `rank`.
172
176
  The rank key is unrounded protein per 100 kcal, then absolute protein, then
173
177
  name. `--max-kcal 0` is valid because zero-calorie records exist.
174
178
 
175
- `per_serving` contains only macros actually known by the source. Missing is
176
- never filled with zero. `complete` exposes whether the full shape is present;
177
- a candidate missing a requested filter macro is excluded and returned in the
178
- source's `unverifiable` or equivalent bucket. Every source emits that bucket,
179
- even when its loader makes it structurally empty.
179
+ `per_serving` contains only figures actually known by the source, whatever
180
+ they are a source that also publishes fibre or sodium puts them here.
181
+ Missing is never filled with zero. `candidate` takes the keys that count as a
182
+ full set from its caller as `required`, because this package does not know what
183
+ a macro is; callers answering the same question share that tuple so `complete`
184
+ keeps meaning the same thing across them. A candidate missing a requested
185
+ filter figure is excluded and returned in the source's `unverifiable` or
186
+ equivalent bucket. Every source emits that bucket, even when its loader makes
187
+ it structurally empty.
180
188
 
181
189
  This contract is the reason the tools can be independent packages: an
182
190
  orchestrator can merge and rank results without knowing which source answered.
@@ -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.3.0"
13
+ version = "0.4.1"
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"
@@ -2,7 +2,6 @@
2
2
 
3
3
  from agentcli.candidates import (
4
4
  KINDS,
5
- MACRO_KEYS,
6
5
  candidate,
7
6
  macro_options,
8
7
  matches,
@@ -22,7 +21,6 @@ from agentcli.skill import skill_group
22
21
 
23
22
  __all__ = [
24
23
  "KINDS",
25
- "MACRO_KEYS",
26
24
  "AssertionFailure",
27
25
  "JsonAwareGroup",
28
26
  "RemoteError",
@@ -16,8 +16,6 @@ from typing import Any
16
16
 
17
17
  import click
18
18
 
19
- MACRO_KEYS = ("kcal", "protein", "fat", "carbs")
20
-
21
19
  KINDS = ("recipe", "meal")
22
20
 
23
21
 
@@ -27,29 +25,34 @@ def candidate(
27
25
  identifier: str,
28
26
  name: str,
29
27
  per_serving: dict[str, float | None],
28
+ required: tuple[str, ...],
30
29
  detail: dict[str, Any] | None = None,
31
30
  ) -> dict[str, Any]:
32
31
  """One comparable option, per serving.
33
32
 
34
- `per_serving` carries every macro the source published and omits the rest.
35
- A macro is never defaulted to zero to fill the shape: a dish whose fat was
36
- never measured is not a fat-free dish, and `complete` is what tells them
37
- apart.
33
+ `per_serving` carries every figure the source published, whatever they are,
34
+ and omits the rest. A figure is never defaulted to zero to fill the shape:
35
+ a dish whose fat was never measured is not a fat-free dish, and `complete`
36
+ is what tells them apart.
37
+
38
+ `required` is the caller's -- which figures it considers a full set. This
39
+ module does not know what a macro is, and a source that publishes fibre or
40
+ sodium should be able to say so without asking permission here. Callers
41
+ that answer the same question share the tuple so `complete` keeps meaning
42
+ the same thing across them.
38
43
  """
39
44
  if kind not in KINDS:
40
45
  raise ValueError(f"unknown candidate kind: {kind}")
41
46
 
42
- macros = {
43
- key: per_serving[key]
44
- for key in MACRO_KEYS
45
- if per_serving.get(key) is not None
47
+ published = {
48
+ key: value for key, value in per_serving.items() if value is not None
46
49
  }
47
50
  return {
48
51
  "kind": kind,
49
52
  "id": identifier,
50
53
  "name": name,
51
- "per_serving": macros,
52
- "complete": len(macros) == len(MACRO_KEYS),
54
+ "per_serving": published,
55
+ "complete": all(key in published for key in required),
53
56
  "detail": detail or {},
54
57
  }
55
58
 
@@ -76,15 +79,15 @@ def matches(
76
79
  ) -> bool:
77
80
  """Whether a candidate provably satisfies the constraints.
78
81
 
79
- A candidate missing the macro a filter asks about is excluded, because it
82
+ A candidate missing the figure a filter asks about is excluded, because it
80
83
  cannot be shown to pass. Callers report those separately rather than
81
84
  dropping them silently — "no results" and "three results I could not check"
82
85
  are different answers.
83
86
  """
84
- macros = record["per_serving"]
85
- kcal, protein = macros.get("kcal"), macros.get("protein")
87
+ published = record["per_serving"]
88
+ kcal, protein = published.get("kcal"), published.get("protein")
86
89
 
87
- # A missing macro fails the filter that asks about it rather than being
90
+ # A missing figure fails the filter that asks about it rather than being
88
91
  # treated as zero, which would pass every ceiling and fail every floor.
89
92
  over = max_kcal is not None and (kcal is None or kcal > max_kcal)
90
93
  under = min_protein is not None and (
@@ -17,6 +17,10 @@ from agentcli.candidates import (
17
17
  FULL = {"kcal": 384.2, "protein": 31.5, "fat": 12.1, "carbs": 38.4}
18
18
  PARTIAL = {"kcal": 540.0, "protein": 45.8, "fat": None, "carbs": None}
19
19
 
20
+ # What the nutrition tools happen to require. This module does not define it --
21
+ # it is a caller's tuple, and these tests stand in for a caller.
22
+ REQUIRED = ("kcal", "protein", "fat", "carbs")
23
+
20
24
 
21
25
  def make(kind: str = "recipe", name: str = "Thing", **macros: object) -> dict:
22
26
  return candidate(
@@ -24,13 +28,18 @@ def make(kind: str = "recipe", name: str = "Thing", **macros: object) -> dict:
24
28
  identifier=name.lower(),
25
29
  name=name,
26
30
  per_serving=macros or FULL,
31
+ required=REQUIRED,
27
32
  )
28
33
 
29
34
 
30
35
  def test_an_unpublished_macro_is_omitted_rather_than_zeroed() -> None:
31
36
  """A dish whose fat was never measured is not a fat-free dish."""
32
37
  partial = candidate(
33
- kind="meal", identifier="x", name="Bowl", per_serving=PARTIAL
38
+ kind="meal",
39
+ identifier="x",
40
+ name="Bowl",
41
+ per_serving=PARTIAL,
42
+ required=REQUIRED,
34
43
  )
35
44
 
36
45
  assert partial["per_serving"] == {"kcal": 540.0, "protein": 45.8}
@@ -38,11 +47,44 @@ def test_an_unpublished_macro_is_omitted_rather_than_zeroed() -> None:
38
47
  assert partial["complete"] is False
39
48
 
40
49
 
41
- def test_complete_means_all_four_macros_present() -> None:
50
+ def test_complete_means_every_figure_the_caller_required() -> None:
42
51
  assert make()["complete"] is True
43
52
  assert make(kcal=1.0, protein=1.0, fat=1.0)["complete"] is False
44
53
 
45
54
 
55
+ def test_a_figure_the_caller_did_not_require_is_still_carried() -> None:
56
+ """A source publishing fibre should not have to ask permission here.
57
+
58
+ Filtering to a fixed set of keys meant a tool that had resolved a fibre
59
+ figure could not publish it, and an agent following the documented rule
60
+ -- look for it in `per_serving` -- concluded it was unavailable.
61
+ """
62
+ record = candidate(
63
+ kind="recipe",
64
+ identifier="soup",
65
+ name="Soup",
66
+ per_serving={**FULL, "dietary_fiber": 4.1, "sodium": 0.4},
67
+ required=REQUIRED,
68
+ )
69
+
70
+ assert record["per_serving"]["dietary_fiber"] == 4.1
71
+ assert record["per_serving"]["sodium"] == 0.4
72
+ assert record["complete"] is True
73
+
74
+
75
+ def test_a_caller_requiring_less_is_complete_with_less() -> None:
76
+ """Two tools answer different questions; neither defines the other's set."""
77
+ record = candidate(
78
+ kind="meal",
79
+ identifier="x",
80
+ name="X",
81
+ per_serving={"kcal": 540.0, "protein": 45.8},
82
+ required=("kcal", "protein"),
83
+ )
84
+
85
+ assert record["complete"] is True
86
+
87
+
46
88
  def test_detail_is_where_the_kinds_differ() -> None:
47
89
  """Nothing shared reads `detail`, so the kinds cannot collide in it."""
48
90
  meal = candidate(
@@ -50,6 +92,7 @@ def test_detail_is_where_the_kinds_differ() -> None:
50
92
  identifier="crust-margherita",
51
93
  name="Margherita",
52
94
  per_serving=FULL,
95
+ required=REQUIRED,
53
96
  detail={"restaurant": "Crust Pizza", "distance_km": 1.5},
54
97
  )
55
98
 
@@ -67,7 +110,13 @@ def test_detail_is_where_the_kinds_differ() -> None:
67
110
  def test_an_unknown_kind_is_refused() -> None:
68
111
  """A third kind is a decision, not something a caller slips in."""
69
112
  with pytest.raises(ValueError, match="unknown candidate kind"):
70
- candidate(kind="snack", identifier="x", name="X", per_serving=FULL)
113
+ candidate(
114
+ kind="snack",
115
+ identifier="x",
116
+ name="X",
117
+ per_serving=FULL,
118
+ required=REQUIRED,
119
+ )
71
120
 
72
121
 
73
122
  @pytest.mark.parametrize(
@@ -102,7 +151,11 @@ def test_a_missing_macro_fails_the_filter_that_asks_about_it() -> None:
102
151
 
103
152
  def test_no_filters_matches_everything_including_incomplete() -> None:
104
153
  partial = candidate(
105
- kind="meal", identifier="x", name="Bowl", per_serving=PARTIAL
154
+ kind="meal",
155
+ identifier="x",
156
+ name="Bowl",
157
+ per_serving=PARTIAL,
158
+ required=REQUIRED,
106
159
  )
107
160
 
108
161
  assert matches(partial) is True
@@ -170,12 +223,14 @@ def test_a_published_zero_is_not_a_missing_measurement() -> None:
170
223
  identifier="coffee",
171
224
  name="Black Coffee",
172
225
  per_serving={"kcal": 0.0, "protein": 0.0, "fat": 0.0, "carbs": 0.0},
226
+ required=REQUIRED,
173
227
  )
174
228
  unmeasured = candidate(
175
229
  kind="meal",
176
230
  identifier="mystery",
177
231
  name="Mystery",
178
232
  per_serving={"protein": 0.0, "fat": 0.0, "carbs": 0.0},
233
+ required=REQUIRED,
179
234
  )
180
235
 
181
236
  assert measured["per_serving"]["kcal"] == 0.0
@@ -67,14 +67,44 @@ class JsonAwareGroup(click.Group):
67
67
  **extra,
68
68
  )
69
69
 
70
+ def _drift_hint(self, ctx: click.Context, exc: Exception) -> str:
71
+ """Why a name this caller expected might not exist.
72
+
73
+ A caller reading a skill older than the binary asks for a command the
74
+ binary has since renamed or dropped, and no other failure looks like
75
+ this. The hint lives here rather than in the skill because the skill
76
+ is the thing that went stale, while the binary is what was upgraded.
77
+ """
78
+ if not isinstance(exc, click.NoSuchOption | click.UsageError):
79
+ return ""
80
+ if "No such command" not in str(exc) and not isinstance(
81
+ exc, click.NoSuchOption
82
+ ):
83
+ return ""
84
+ if "skill" not in self.commands:
85
+ return ""
86
+ tool = ctx.find_root().info_name or "this tool"
87
+ return (
88
+ f" If this was documented, the installed skill predates this "
89
+ f"version; run `{tool} skill install`."
90
+ )
91
+
70
92
  def invoke(self, ctx: click.Context) -> Any:
71
93
  try:
72
94
  return super().invoke(ctx)
73
95
  except click.ClickException as exc:
96
+ hint = self._drift_hint(ctx, exc)
74
97
  # The human path stays click's own, which prints the usage block
75
98
  # too: worth more to a person than a uniform shape.
76
99
  if not self._json_requested:
100
+ if hint:
101
+ # Re-raised rather than edited: click marks the message
102
+ # final, and a UsageError still prints usage and exits 2.
103
+ raise click.UsageError(
104
+ f"{exc.format_message()}{hint}",
105
+ ctx=getattr(exc, "ctx", None),
106
+ ) from exc
77
107
  raise
78
108
 
79
- emit_error(exc.format_message(), json_output=True)
109
+ emit_error(f"{exc.format_message()}{hint}", json_output=True)
80
110
  ctx.exit(exc.exit_code)
@@ -6,7 +6,7 @@ import click
6
6
  import pytest
7
7
  from click.testing import CliRunner
8
8
 
9
- from agentcli import JsonAwareGroup, UsageError, emit
9
+ from agentcli import JsonAwareGroup, UsageError, emit, skill_group
10
10
 
11
11
 
12
12
  @click.group(cls=JsonAwareGroup)
@@ -85,3 +85,37 @@ def test_main_accepts_clicks_own_positional_arguments(
85
85
  "ok": True,
86
86
  "data": {"limit": 10},
87
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
@@ -96,9 +96,9 @@ def _primary_target(destination: Path | None, home: Path, name: str) -> Path:
96
96
  def _is_our_skill(target: Path, *, name: str) -> bool:
97
97
  """Does this directory actually hold the skill we installed?
98
98
 
99
- A broken symlink counts. `--link` into a `uvx` environment dies on
100
- `uv cache prune`, and refusing to clean up exactly that wreckage would be
101
- perverse -- the directory is still one this tool created.
99
+ A broken symlink counts. Older versions could install one, and refusing to
100
+ clean up exactly that wreckage would be perverse -- the directory is still
101
+ one this tool created.
102
102
  """
103
103
  manifest = target / "SKILL.md"
104
104
  if manifest.is_symlink() and not manifest.exists():
@@ -151,14 +151,15 @@ def _refusal(target: Path, *, name: str) -> str | None:
151
151
  return None
152
152
 
153
153
 
154
- def _place(source: Path, target: Path, *, name: str, link: bool) -> str:
154
+ def _place(source: Path, target: Path, *, name: str) -> str:
155
155
  """Place SKILL.md into a skill directory of its own.
156
156
 
157
- Copying is the default because a link points into the environment this CLI
158
- was installed into: run under `uvx`, that is a prunable cache, so the skill
159
- works today and vanishes after `uv cache prune`. Copying costs a stale
160
- skill after an upgrade, which is cheap here -- the skill is a router, and
161
- the manual it routes to (`<tool> guide`) ships in the binary.
157
+ Always a copy. A link points into the environment this CLI was installed
158
+ into, and that path is not stable: it carries the interpreter version, so
159
+ 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.
162
163
  """
163
164
  refusal = _refusal(target, name=name)
164
165
  if refusal is not None:
@@ -167,27 +168,37 @@ def _place(source: Path, target: Path, *, name: str, link: bool) -> str:
167
168
  if target.exists() or target.is_symlink():
168
169
  _remove(target)
169
170
 
170
- # The directory is always real, and named for the skill as the spec
171
- # requires; only its contents are ever linked.
171
+ # The directory is named for the skill, as the spec requires.
172
172
  target.mkdir(parents=True, exist_ok=True)
173
- manifest = target / "SKILL.md"
174
-
175
- if link:
176
- try:
177
- manifest.symlink_to(source)
178
- except OSError as exc:
179
- # Windows needs Developer Mode or admin rights for symlinks. A
180
- # copy is a worse answer than a link but a much better one than
181
- # a traceback.
182
- click.echo(f"# symlink failed ({exc}); copying instead", err=True)
183
- else:
184
- return f"linked {manifest} -> {source}"
185
-
186
- shutil.copy2(source, manifest)
173
+ shutil.copy2(source, target / "SKILL.md")
187
174
  return f"copied {target}"
188
175
 
189
176
 
190
- def _status_rows(home: Path, name: str) -> list[dict[str, Any]]:
177
+ def _state(path: Path, source: Path | None, *, name: str) -> str:
178
+ """Whether a location holds this skill, and whether it is the current one.
179
+
180
+ Presence alone said `installed` for a copy many releases old, because
181
+ upgrading a package never refreshes a skill already on disk. Comparing the
182
+ bytes is what makes that drift visible instead of silent.
183
+ """
184
+ if not _is_our_skill(path, name=name):
185
+ return "absent"
186
+ if source is None or not source.is_file():
187
+ return "installed"
188
+ installed = path / "SKILL.md"
189
+ try:
190
+ return (
191
+ "current"
192
+ if installed.read_bytes() == source.read_bytes()
193
+ else "stale"
194
+ )
195
+ except OSError:
196
+ return "installed"
197
+
198
+
199
+ def _status_rows(
200
+ home: Path, name: str, source: Path | None = None
201
+ ) -> list[dict[str, Any]]:
191
202
  """One row per known location, shared first, whether present or not."""
192
203
  locations = [("Shared (.agents)", home / SHARED_DIR / name)]
193
204
  locations += [
@@ -195,22 +206,32 @@ def _status_rows(home: Path, name: str) -> list[dict[str, Any]]:
195
206
  for label, (_, skills) in TOOL_DIRS.items()
196
207
  ]
197
208
 
198
- return [
199
- {
200
- "tool": label,
201
- "path": str(path),
202
- "installed": _is_our_skill(path, name=name),
203
- }
204
- for label, path in locations
205
- ]
209
+ rows = []
210
+ for label, path in locations:
211
+ state = _state(path, source, name=name)
212
+ rows.append(
213
+ {
214
+ "tool": label,
215
+ "path": str(path),
216
+ "installed": state != "absent",
217
+ "state": state,
218
+ }
219
+ )
220
+ return rows
206
221
 
207
222
 
208
223
  def _status_lines(payload: dict[str, Any]) -> Iterable[str]:
209
224
  """Render `status` for a human: fixed columns, no table drawing."""
210
225
  for row in payload["locations"]:
211
- mark = "installed" if row["installed"] else "-"
226
+ mark = "-" if row["state"] == "absent" else row["state"]
212
227
  yield f"{mark:<10} {row['tool']:<16} {row['path']}"
213
228
 
229
+ if any(row["state"] == "stale" for row in payload["locations"]):
230
+ yield (
231
+ f"A stale copy predates this version. Run "
232
+ f"`{payload['skill']} skill install` to refresh it."
233
+ )
234
+
214
235
 
215
236
  def skill_group(*, name: str, package: str) -> click.Group:
216
237
  """Build the `skill` command group for one tool.
@@ -235,8 +256,7 @@ def skill_group(*, name: str, package: str) -> click.Group:
235
256
  \b
236
257
  {name} skill install # everywhere it is wanted
237
258
  {name} skill install --to ~/.claude/skills # just this one
238
- {name} skill install --to .agents/skills # this repository only
239
- {name} skill install --link # track package upgrades""",
259
+ {name} skill install --to .agents/skills # this repository only""",
240
260
  )
241
261
  @click.option(
242
262
  "--to",
@@ -244,19 +264,9 @@ def skill_group(*, name: str, package: str) -> click.Group:
244
264
  type=click.Path(file_okay=False, path_type=Path),
245
265
  help=target_help,
246
266
  )
247
- @click.option(
248
- "--link",
249
- is_flag=True,
250
- help="Symlink instead of copying, so package upgrades take effect "
251
- "immediately. Only safe for a durable install and a private skills "
252
- "directory: a link into a `uvx` environment dies on `uv cache "
253
- "prune`, and one committed to a repository is broken for everyone "
254
- "else. Re-running install is the portable way to refresh.",
255
- )
256
267
  @click.option("--dry-run", is_flag=True, help=dry_run_help)
257
268
  def install_command(
258
269
  destination: Path | None,
259
- link: bool,
260
270
  dry_run: bool,
261
271
  ) -> None:
262
272
  """Install the Agent Skill into an agent's skills directory.
@@ -291,7 +301,7 @@ def skill_group(*, name: str, package: str) -> click.Group:
291
301
  continue
292
302
 
293
303
  try:
294
- click.echo(_place(source, target, name=name, link=link))
304
+ click.echo(_place(source, target, name=name))
295
305
  except OSError as exc:
296
306
  raise click.ClickException(
297
307
  f"could not install into {target}: {exc.strerror or exc}"
@@ -364,9 +374,13 @@ def skill_group(*, name: str, package: str) -> click.Group:
364
374
  @json_option
365
375
  def status_command(json_output: bool) -> None:
366
376
  """Show every known location and whether the skill is installed."""
377
+ try:
378
+ source: Path | None = packaged_skill(name=name, package=package)
379
+ except click.ClickException:
380
+ source = None
367
381
  payload = {
368
382
  "skill": name,
369
- "locations": _status_rows(Path.home(), name),
383
+ "locations": _status_rows(Path.home(), name, source),
370
384
  }
371
385
  emit(payload, json_output=json_output, human=_status_lines)
372
386
 
@@ -138,13 +138,13 @@ def test_install_refreshes_our_own_copy(tool: Tool) -> None:
138
138
  assert (tool.shared() / "SKILL.md").read_text() == MANIFEST
139
139
 
140
140
 
141
- def test_install_link_makes_a_symlink(tool: Tool) -> None:
141
+ def test_install_refuses_to_link(tool: Tool) -> None:
142
+ """A link points into the environment, whose path carries the interpreter
143
+ version, so a rebuild elsewhere leaves the skill silently absent."""
142
144
  result = tool.run("install", "--link")
143
145
 
144
- assert result.exit_code == 0
145
- manifest = tool.shared() / "SKILL.md"
146
- assert manifest.is_symlink()
147
- assert manifest.resolve() == tool.source.resolve()
146
+ assert result.exit_code != 0
147
+ assert "--link" in result.output
148
148
 
149
149
 
150
150
  def test_install_covers_detected_tools_without_a_flag(tool: Tool) -> None:
@@ -228,10 +228,12 @@ def test_uninstall_refuses_a_foreign_directory(tool: Tool) -> None:
228
228
 
229
229
 
230
230
  def test_uninstall_removes_a_broken_symlink(tool: Tool) -> None:
231
- """A `--link` install survived `uv cache prune`; clean it up anyway."""
232
- tool.run("install", "--link")
233
- tool.source.unlink()
231
+ """A link an older version installed, now dangling. Still ours to clean."""
232
+ tool.run("install")
234
233
  manifest = tool.shared() / "SKILL.md"
234
+ manifest.unlink()
235
+ manifest.symlink_to(tool.source)
236
+ tool.source.unlink()
235
237
  assert manifest.is_symlink() and not manifest.exists()
236
238
 
237
239
  result = tool.run("uninstall")
@@ -300,24 +302,6 @@ def test_status_human_lists_every_location(tool: Tool) -> None:
300
302
  assert "installed" not in result.output
301
303
 
302
304
 
303
- def test_install_link_failure_falls_back_to_copying(
304
- tool: Tool, monkeypatch: pytest.MonkeyPatch
305
- ) -> None:
306
- """Windows without Developer Mode: a copy beats a traceback."""
307
-
308
- def refuse(self, target, **kwargs):
309
- raise OSError("symlinks need a privilege you do not have")
310
-
311
- monkeypatch.setattr(Path, "symlink_to", refuse)
312
-
313
- result = tool.run("install", "--link")
314
-
315
- assert result.exit_code == 0
316
- manifest = tool.shared() / "SKILL.md"
317
- assert not manifest.is_symlink()
318
- assert manifest.read_text() == MANIFEST
319
-
320
-
321
305
  def test_install_wraps_oserror(
322
306
  tool: Tool, monkeypatch: pytest.MonkeyPatch
323
307
  ) -> None:
@@ -381,3 +365,33 @@ def test_uninstall_matches_what_install_wrote(tool: Tool) -> None:
381
365
  Path(".gemini") / "config" / "skills",
382
366
  ):
383
367
  assert not (tool.home / relative / NAME).exists()
368
+
369
+
370
+ def test_status_calls_a_matching_copy_current(tool: Tool) -> None:
371
+ tool.run("install")
372
+
373
+ result = tool.run("status", "--json")
374
+
375
+ rows = json.loads(result.output)["data"]["locations"]
376
+ shared = next(r for r in rows if r["tool"].startswith("Shared"))
377
+ assert shared["state"] == "current"
378
+
379
+
380
+ def test_status_reports_a_copy_the_package_has_moved_past(tool: Tool) -> None:
381
+ """Upgrading a package never refreshes a skill already on disk, so
382
+ presence alone reported `installed` for a copy releases out of date."""
383
+ tool.run("install")
384
+ tool.source.write_text(MANIFEST + "a section added since\n")
385
+
386
+ result = tool.run("status")
387
+
388
+ assert "stale" in result.output
389
+ assert "skill install" in result.output
390
+
391
+
392
+ def test_status_still_answers_for_an_absent_location(tool: Tool) -> None:
393
+ result = tool.run("status", "--json")
394
+
395
+ rows = json.loads(result.output)["data"]["locations"]
396
+ assert all(row["state"] == "absent" for row in rows)
397
+ assert all(row["installed"] is False for row in rows)
@@ -16,7 +16,7 @@ wheels = [
16
16
 
17
17
  [[package]]
18
18
  name = "click-agentcli"
19
- version = "0.3.0"
19
+ version = "0.4.1"
20
20
  source = { editable = "." }
21
21
  dependencies = [
22
22
  { name = "click" },
File without changes