click-agentcli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentcli/__init__.py +43 -0
- agentcli/candidates.py +141 -0
- agentcli/candidates_test.py +195 -0
- agentcli/exits.py +48 -0
- agentcli/exits_test.py +66 -0
- agentcli/group.py +82 -0
- agentcli/group_test.py +89 -0
- agentcli/guide.py +21 -0
- agentcli/guide_test.py +14 -0
- agentcli/output.py +85 -0
- agentcli/output_test.py +91 -0
- agentcli/skill.py +375 -0
- agentcli/skill_test.py +385 -0
- click_agentcli-0.1.0.dist-info/METADATA +116 -0
- click_agentcli-0.1.0.dist-info/RECORD +17 -0
- click_agentcli-0.1.0.dist-info/WHEEL +4 -0
- click_agentcli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentcli/guide_test.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The manual ships in the binary, so it cannot go stale."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from click.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from agentcli.guide import guide_command
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_guide_prints_the_text_stripped() -> None:
|
|
11
|
+
result = CliRunner().invoke(guide_command("\n body \n\n"), [])
|
|
12
|
+
|
|
13
|
+
assert result.exit_code == 0
|
|
14
|
+
assert result.output == "body\n"
|
agentcli/output.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""One JSON object, or a human table. Never both, never anything else.
|
|
2
|
+
|
|
3
|
+
The whole point of `--json` is that a composing agent can read stdout with a
|
|
4
|
+
parser instead of a regular expression, so the machine path emits exactly one
|
|
5
|
+
object and the human path is free to be pretty.
|
|
6
|
+
|
|
7
|
+
Success and failure are deliberately symmetric:
|
|
8
|
+
|
|
9
|
+
{"ok": true, "data": {...}}
|
|
10
|
+
{"ok": false, "error": {"message": "..."}}
|
|
11
|
+
|
|
12
|
+
so a consumer branches on one key it can always rely on, in every tool and
|
|
13
|
+
every command. The alternative -- a bare payload on success and a differently
|
|
14
|
+
shaped object on failure -- makes every caller special-case both, which is how
|
|
15
|
+
four tools end up with four contracts.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import Callable, Iterable
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def dumps(payload: Any) -> str:
|
|
28
|
+
"""Compact, stable JSON. Sorted keys would fight the record key order."""
|
|
29
|
+
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit(
|
|
33
|
+
data: dict[str, Any],
|
|
34
|
+
*,
|
|
35
|
+
json_output: bool,
|
|
36
|
+
human: Callable[[dict[str, Any]], Iterable[str]],
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Write one successful result, in whichever format was requested.
|
|
39
|
+
|
|
40
|
+
The envelope is added here so no command can forget it. `human` receives
|
|
41
|
+
the unwrapped data, because the wrapper exists for parsers, not people.
|
|
42
|
+
"""
|
|
43
|
+
if json_output:
|
|
44
|
+
click.echo(dumps({"ok": True, "data": data}))
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
for line in human(data):
|
|
48
|
+
click.echo(line)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def emit_error(message: str, *, json_output: bool) -> None:
|
|
52
|
+
"""Report a failure on stdout when JSON was requested, stderr otherwise.
|
|
53
|
+
|
|
54
|
+
Requesting JSON is a promise that stdout is parseable, and a caller that
|
|
55
|
+
has to merge two streams to find the error does not have that.
|
|
56
|
+
"""
|
|
57
|
+
if json_output:
|
|
58
|
+
click.echo(dumps({"ok": False, "error": {"message": message}}))
|
|
59
|
+
else:
|
|
60
|
+
click.echo(message, err=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def json_option(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
64
|
+
"""The shared `--json` flag, identical in every tool."""
|
|
65
|
+
return click.option(
|
|
66
|
+
"--json",
|
|
67
|
+
"json_output",
|
|
68
|
+
is_flag=True,
|
|
69
|
+
help="Emit exactly one JSON object on stdout.",
|
|
70
|
+
)(f)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def limit_option(default: int = 10) -> Callable[..., Any]:
|
|
74
|
+
"""The shared `--limit` flag. Rejects negatives rather than clamping."""
|
|
75
|
+
|
|
76
|
+
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
77
|
+
return click.option(
|
|
78
|
+
"--limit",
|
|
79
|
+
type=click.IntRange(min=0),
|
|
80
|
+
default=default,
|
|
81
|
+
show_default=True,
|
|
82
|
+
help="Maximum results to return.",
|
|
83
|
+
)(f)
|
|
84
|
+
|
|
85
|
+
return decorator
|
agentcli/output_test.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""One JSON object on stdout, or human text. Never both."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import pytest
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from agentcli.output import emit, emit_error, json_option, limit_option
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_json_emits_exactly_one_object(capsys) -> None:
|
|
15
|
+
emit(
|
|
16
|
+
{"items": [1, 2], "count": 2},
|
|
17
|
+
json_output=True,
|
|
18
|
+
human=lambda payload: ["unused"],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
out = capsys.readouterr()
|
|
22
|
+
assert out.err == ""
|
|
23
|
+
# Exactly one line, and that line is the payload inside the envelope.
|
|
24
|
+
assert json.loads(out.out.strip()) == {
|
|
25
|
+
"ok": True,
|
|
26
|
+
"data": {"items": [1, 2], "count": 2},
|
|
27
|
+
}
|
|
28
|
+
assert len(out.out.strip().splitlines()) == 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_human_path_never_emits_json(capsys) -> None:
|
|
32
|
+
emit({"n": 1}, json_output=False, human=lambda p: [f"n is {p['n']}"])
|
|
33
|
+
|
|
34
|
+
out = capsys.readouterr()
|
|
35
|
+
assert out.out == "n is 1\n"
|
|
36
|
+
with pytest.raises(json.JSONDecodeError):
|
|
37
|
+
json.loads(out.out)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_emit_error_json_goes_to_stdout(capsys) -> None:
|
|
41
|
+
"""`--json` promises stdout is parseable, failures included."""
|
|
42
|
+
emit_error("boom", json_output=True)
|
|
43
|
+
|
|
44
|
+
out = capsys.readouterr()
|
|
45
|
+
assert out.err == ""
|
|
46
|
+
assert json.loads(out.out) == {
|
|
47
|
+
"ok": False,
|
|
48
|
+
"error": {"message": "boom"},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_emit_error_human_goes_to_stderr(capsys) -> None:
|
|
53
|
+
emit_error("boom", json_output=False)
|
|
54
|
+
|
|
55
|
+
out = capsys.readouterr()
|
|
56
|
+
assert out.out == ""
|
|
57
|
+
assert out.err == "boom\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_json_option_defaults_to_human() -> None:
|
|
61
|
+
@click.command()
|
|
62
|
+
@json_option
|
|
63
|
+
def cmd(json_output: bool) -> None:
|
|
64
|
+
click.echo(str(json_output))
|
|
65
|
+
|
|
66
|
+
assert CliRunner().invoke(cmd, []).output == "False\n"
|
|
67
|
+
assert CliRunner().invoke(cmd, ["--json"]).output == "True\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_limit_option_rejects_negative() -> None:
|
|
71
|
+
"""A negative limit is a bad flag, not something to clamp silently."""
|
|
72
|
+
|
|
73
|
+
@click.command()
|
|
74
|
+
@limit_option()
|
|
75
|
+
def cmd(limit: int) -> None:
|
|
76
|
+
click.echo(str(limit))
|
|
77
|
+
|
|
78
|
+
result = CliRunner().invoke(cmd, ["--limit", "-1"])
|
|
79
|
+
|
|
80
|
+
assert result.exit_code != 0
|
|
81
|
+
assert "--limit" in result.output
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_limit_option_default_and_zero() -> None:
|
|
85
|
+
@click.command()
|
|
86
|
+
@limit_option(5)
|
|
87
|
+
def cmd(limit: int) -> None:
|
|
88
|
+
click.echo(str(limit))
|
|
89
|
+
|
|
90
|
+
assert CliRunner().invoke(cmd, []).output == "5\n"
|
|
91
|
+
assert CliRunner().invoke(cmd, ["--limit", "0"]).output == "0\n"
|
agentcli/skill.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""`<tool> skill` — put the packaged Agent Skill where agents find it.
|
|
2
|
+
|
|
3
|
+
Installing the CLI is not enough: agents discover skills by scanning specific
|
|
4
|
+
directories, and those directories are per-tool. `~/.agents/skills` is the
|
|
5
|
+
emerging cross-tool location, but several tools only read their own. So rather
|
|
6
|
+
than guess, `install` writes to the shared location and reports every tool
|
|
7
|
+
directory it can see, with the command to cover those too.
|
|
8
|
+
|
|
9
|
+
Parameterised by skill name and package because the two hand-written copies
|
|
10
|
+
this replaces had already drifted apart in exactly the guards that matter.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
|
|
23
|
+
from agentcli.output import emit, json_option
|
|
24
|
+
|
|
25
|
+
# The tool-agnostic location. Read by Gemini CLI and others; a reasonable
|
|
26
|
+
# default even where a tool also keeps its own directory.
|
|
27
|
+
SHARED_DIR = Path(".agents") / "skills"
|
|
28
|
+
|
|
29
|
+
# Per-tool directories, keyed by the marker that shows the tool is installed.
|
|
30
|
+
TOOL_DIRS: dict[str, tuple[Path, Path]] = {
|
|
31
|
+
"Claude Code": (Path(".claude"), Path(".claude") / "skills"),
|
|
32
|
+
"Gemini CLI": (Path(".gemini"), Path(".gemini") / "skills"),
|
|
33
|
+
"Antigravity": (Path(".gemini"), Path(".gemini") / "config" / "skills"),
|
|
34
|
+
"Cursor": (Path(".cursor"), Path(".cursor") / "skills"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _package_root(package: str) -> Path | None:
|
|
39
|
+
"""The installed directory of the consuming tool's package."""
|
|
40
|
+
try:
|
|
41
|
+
return Path(str(resources.files(package)))
|
|
42
|
+
except (ModuleNotFoundError, TypeError):
|
|
43
|
+
# A tool that cannot import its own package has larger problems than
|
|
44
|
+
# a missing skill, and a traceback here would only hide them.
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _skill_candidates(name: str, package: str) -> list[Path]:
|
|
49
|
+
"""Every place SKILL.md is allowed to live, best first.
|
|
50
|
+
|
|
51
|
+
SKILL.md is authored at the repository root, where it is visible, and
|
|
52
|
+
mapped into the package at build time. Both have to work: the packaged
|
|
53
|
+
path for real installs, the checkout path when running from source. The
|
|
54
|
+
root is one level above the package for a flat layout and two for `src/`.
|
|
55
|
+
"""
|
|
56
|
+
root = _package_root(package)
|
|
57
|
+
if root is None:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
packaged = root / "skills" / name / "SKILL.md"
|
|
61
|
+
return [packaged] + [p / "SKILL.md" for p in list(root.parents)[:2]]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def packaged_skill(*, name: str, package: str) -> Path:
|
|
65
|
+
"""Locate SKILL.md, whether running from a wheel or a source checkout."""
|
|
66
|
+
candidates = _skill_candidates(name, package)
|
|
67
|
+
for candidate in candidates:
|
|
68
|
+
if candidate.is_file():
|
|
69
|
+
return candidate
|
|
70
|
+
|
|
71
|
+
if not candidates:
|
|
72
|
+
raise click.ClickException(f"package {package} is not importable")
|
|
73
|
+
|
|
74
|
+
looked = ", ".join(str(candidate) for candidate in candidates)
|
|
75
|
+
raise click.ClickException(f"SKILL.md not found; looked in {looked}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def detected_tools(home: Path) -> dict[str, Path]:
|
|
79
|
+
"""Return skills directories for the agent tools present on this machine."""
|
|
80
|
+
return {
|
|
81
|
+
label: home / skills
|
|
82
|
+
for label, (marker, skills) in TOOL_DIRS.items()
|
|
83
|
+
if (home / marker).is_dir()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _primary_target(destination: Path | None, home: Path, name: str) -> Path:
|
|
88
|
+
"""The one location acted on when no sweep was requested.
|
|
89
|
+
|
|
90
|
+
A repository-scoped install is just `--to .agents/skills`, so it needs no
|
|
91
|
+
flag of its own.
|
|
92
|
+
"""
|
|
93
|
+
if destination is not None:
|
|
94
|
+
return destination / name
|
|
95
|
+
return home / SHARED_DIR / name
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_our_skill(target: Path, *, name: str) -> bool:
|
|
99
|
+
"""Does this directory actually hold the skill we installed?
|
|
100
|
+
|
|
101
|
+
A broken symlink counts. `--link` into a `uvx` environment dies on
|
|
102
|
+
`uv cache prune`, and refusing to clean up exactly that wreckage would be
|
|
103
|
+
perverse -- the directory is still one this tool created.
|
|
104
|
+
"""
|
|
105
|
+
manifest = target / "SKILL.md"
|
|
106
|
+
if manifest.is_symlink() and not manifest.exists():
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
if not manifest.is_file():
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
# Unreadable or not text: not something this tool wrote, and certainly not
|
|
113
|
+
# something to delete on the strength of a guess.
|
|
114
|
+
try:
|
|
115
|
+
text = manifest.read_text(encoding="utf-8", errors="replace")
|
|
116
|
+
except OSError:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
return f"name: {name}" in text
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _remove(target: Path) -> None:
|
|
123
|
+
"""Delete an installed skill, link or directory alike."""
|
|
124
|
+
if target.is_symlink() or target.is_file():
|
|
125
|
+
target.unlink()
|
|
126
|
+
else:
|
|
127
|
+
shutil.rmtree(target)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _refusal(target: Path, *, name: str) -> str | None:
|
|
131
|
+
"""Why `install` would decline this target, or None if it would proceed.
|
|
132
|
+
|
|
133
|
+
One function so the guard and the `--dry-run` prediction cannot drift: a
|
|
134
|
+
dry run that promises a success the real command declines is worse than no
|
|
135
|
+
dry run at all.
|
|
136
|
+
|
|
137
|
+
Replacing *our own* skill needs no permission: the packaged manifest is
|
|
138
|
+
the source of truth, so re-installing is idempotent and is how a stale
|
|
139
|
+
copy gets refreshed. Whatever happens to sit at a mistyped `--to` is a
|
|
140
|
+
different matter -- deleting a tree is not something to do on the
|
|
141
|
+
strength of its name.
|
|
142
|
+
"""
|
|
143
|
+
if not target.exists() and not target.is_symlink():
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
if not _is_our_skill(target, name=name):
|
|
147
|
+
return (
|
|
148
|
+
f"{target} exists and does not contain the {name} skill; "
|
|
149
|
+
f"refusing to replace it. Remove it by hand if that is really "
|
|
150
|
+
f"intended."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _place(source: Path, target: Path, *, name: str, link: bool) -> str:
|
|
157
|
+
"""Place SKILL.md into a skill directory of its own.
|
|
158
|
+
|
|
159
|
+
Copying is the default because a link points into the environment this CLI
|
|
160
|
+
was installed into: run under `uvx`, that is a prunable cache, so the skill
|
|
161
|
+
works today and vanishes after `uv cache prune`. Copying costs a stale
|
|
162
|
+
skill after an upgrade, which is cheap here -- the skill is a router, and
|
|
163
|
+
the manual it routes to (`<tool> guide`) ships in the binary.
|
|
164
|
+
"""
|
|
165
|
+
refusal = _refusal(target, name=name)
|
|
166
|
+
if refusal is not None:
|
|
167
|
+
raise click.ClickException(refusal)
|
|
168
|
+
|
|
169
|
+
if target.exists() or target.is_symlink():
|
|
170
|
+
_remove(target)
|
|
171
|
+
|
|
172
|
+
# The directory is always real, and named for the skill as the spec
|
|
173
|
+
# requires; only its contents are ever linked.
|
|
174
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
manifest = target / "SKILL.md"
|
|
176
|
+
|
|
177
|
+
if link:
|
|
178
|
+
try:
|
|
179
|
+
manifest.symlink_to(source)
|
|
180
|
+
except OSError as exc:
|
|
181
|
+
# Windows needs Developer Mode or admin rights for symlinks. A
|
|
182
|
+
# copy is a worse answer than a link but a much better one than
|
|
183
|
+
# a traceback.
|
|
184
|
+
click.echo(f"# symlink failed ({exc}); copying instead", err=True)
|
|
185
|
+
else:
|
|
186
|
+
return f"linked {manifest} -> {source}"
|
|
187
|
+
|
|
188
|
+
shutil.copy2(source, manifest)
|
|
189
|
+
return f"copied {target}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _status_rows(home: Path, name: str) -> list[dict[str, Any]]:
|
|
193
|
+
"""One row per known location, shared first, whether present or not."""
|
|
194
|
+
locations = [("Shared (.agents)", home / SHARED_DIR / name)]
|
|
195
|
+
locations += [
|
|
196
|
+
(label, home / skills / name)
|
|
197
|
+
for label, (_, skills) in TOOL_DIRS.items()
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
return [
|
|
201
|
+
{
|
|
202
|
+
"tool": label,
|
|
203
|
+
"path": str(path),
|
|
204
|
+
"installed": _is_our_skill(path, name=name),
|
|
205
|
+
}
|
|
206
|
+
for label, path in locations
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _status_lines(payload: dict[str, Any]) -> Iterable[str]:
|
|
211
|
+
"""Render `status` for a human: fixed columns, no table drawing."""
|
|
212
|
+
for row in payload["locations"]:
|
|
213
|
+
mark = "installed" if row["installed"] else "-"
|
|
214
|
+
yield f"{mark:<10} {row['tool']:<16} {row['path']}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def skill_group(*, name: str, package: str) -> click.Group:
|
|
218
|
+
"""Build the `skill` command group for one tool.
|
|
219
|
+
|
|
220
|
+
`name` is both the skill name and the binary that carries it, so it also
|
|
221
|
+
spells the commands printed in help text.
|
|
222
|
+
"""
|
|
223
|
+
target_help = (
|
|
224
|
+
f"A skills directory to act on. The skill lives in a `{name}` "
|
|
225
|
+
"subdirectory of it, as the spec requires."
|
|
226
|
+
)
|
|
227
|
+
dry_run_help = "Print what would happen without touching the filesystem."
|
|
228
|
+
|
|
229
|
+
@click.group("skill")
|
|
230
|
+
def skill() -> None:
|
|
231
|
+
"""Install the packaged Agent Skill so agents can discover this tool."""
|
|
232
|
+
|
|
233
|
+
@skill.command(
|
|
234
|
+
"install",
|
|
235
|
+
epilog=f"""Examples:
|
|
236
|
+
|
|
237
|
+
\b
|
|
238
|
+
{name} skill install # everywhere it is wanted
|
|
239
|
+
{name} skill install --to ~/.claude/skills # just this one
|
|
240
|
+
{name} skill install --to .agents/skills # this repository only
|
|
241
|
+
{name} skill install --link # track package upgrades""",
|
|
242
|
+
)
|
|
243
|
+
@click.option(
|
|
244
|
+
"--to",
|
|
245
|
+
"destination",
|
|
246
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
247
|
+
help=target_help,
|
|
248
|
+
)
|
|
249
|
+
@click.option(
|
|
250
|
+
"--link",
|
|
251
|
+
is_flag=True,
|
|
252
|
+
help="Symlink instead of copying, so package upgrades take effect "
|
|
253
|
+
"immediately. Only safe for a durable install and a private skills "
|
|
254
|
+
"directory: a link into a `uvx` environment dies on `uv cache "
|
|
255
|
+
"prune`, and one committed to a repository is broken for everyone "
|
|
256
|
+
"else. Re-running install is the portable way to refresh.",
|
|
257
|
+
)
|
|
258
|
+
@click.option("--dry-run", is_flag=True, help=dry_run_help)
|
|
259
|
+
def install_command(
|
|
260
|
+
destination: Path | None,
|
|
261
|
+
link: bool,
|
|
262
|
+
dry_run: bool,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Install the Agent Skill into an agent's skills directory.
|
|
265
|
+
|
|
266
|
+
With no options this installs everywhere the skill is wanted: the
|
|
267
|
+
cross-tool ~/.agents/skills, plus the own skills directory of every
|
|
268
|
+
agent tool detected on this machine. A tool that is not installed is
|
|
269
|
+
never created. Use --to to target exactly one directory instead.
|
|
270
|
+
"""
|
|
271
|
+
source = packaged_skill(name=name, package=package)
|
|
272
|
+
|
|
273
|
+
home = Path.home()
|
|
274
|
+
targets = [_primary_target(destination, home, name)]
|
|
275
|
+
|
|
276
|
+
# `--to` is the way to ask for one directory, so it opts out of the
|
|
277
|
+
# sweep rather than adding to it.
|
|
278
|
+
if destination is None:
|
|
279
|
+
targets += [
|
|
280
|
+
directory / name for directory in detected_tools(home).values()
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
# Two tools can name the same directory, and `--to` can name one a
|
|
284
|
+
# sweep already covers; installing twice would report a spurious
|
|
285
|
+
# "already exists".
|
|
286
|
+
for target in dict.fromkeys(targets):
|
|
287
|
+
if dry_run:
|
|
288
|
+
refusal = _refusal(target, name=name)
|
|
289
|
+
if refusal is None:
|
|
290
|
+
click.echo(f"would install {target}")
|
|
291
|
+
else:
|
|
292
|
+
click.echo(f"would REFUSE {refusal}")
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
click.echo(_place(source, target, name=name, link=link))
|
|
297
|
+
except OSError as exc:
|
|
298
|
+
raise click.ClickException(
|
|
299
|
+
f"could not install into {target}: {exc.strerror or exc}"
|
|
300
|
+
) from None
|
|
301
|
+
|
|
302
|
+
@skill.command(
|
|
303
|
+
"uninstall",
|
|
304
|
+
epilog=f"""Examples:
|
|
305
|
+
|
|
306
|
+
\b
|
|
307
|
+
{name} skill uninstall # every known location
|
|
308
|
+
{name} skill uninstall --to ~/.claude/skills
|
|
309
|
+
{name} skill uninstall --to .agents/skills""",
|
|
310
|
+
)
|
|
311
|
+
@click.option(
|
|
312
|
+
"--to",
|
|
313
|
+
"destination",
|
|
314
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
315
|
+
help=target_help,
|
|
316
|
+
)
|
|
317
|
+
@click.option("--dry-run", is_flag=True, help=dry_run_help)
|
|
318
|
+
def uninstall_command(destination: Path | None, dry_run: bool) -> None:
|
|
319
|
+
"""Remove the Agent Skill from an agent's skills directory.
|
|
320
|
+
|
|
321
|
+
With no options this mirrors install and clears every known location,
|
|
322
|
+
so an install cannot strand copies an uninstall then leaves behind.
|
|
323
|
+
Only ever removes a directory that actually holds this skill, so
|
|
324
|
+
pointing --to somewhere unexpected fails rather than deleting
|
|
325
|
+
someone's work.
|
|
326
|
+
"""
|
|
327
|
+
home = Path.home()
|
|
328
|
+
targets = [_primary_target(destination, home, name)]
|
|
329
|
+
|
|
330
|
+
# Cleanup covers every known location rather than only the tools still
|
|
331
|
+
# present: an uninstalled tool can leave a skill behind, and that is
|
|
332
|
+
# exactly what needs removing. Missing ones are skipped quietly.
|
|
333
|
+
if destination is None:
|
|
334
|
+
targets += [
|
|
335
|
+
home / skills / name for _, skills in TOOL_DIRS.values()
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
removed = 0
|
|
339
|
+
for target in dict.fromkeys(targets):
|
|
340
|
+
if not target.exists() and not target.is_symlink():
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
if not _is_our_skill(target, name=name):
|
|
344
|
+
raise click.ClickException(
|
|
345
|
+
f"{target} does not contain the {name} skill; refusing "
|
|
346
|
+
f"to delete it. Remove it by hand if that is really "
|
|
347
|
+
f"intended."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if dry_run:
|
|
351
|
+
click.echo(f"would remove {target}")
|
|
352
|
+
else:
|
|
353
|
+
try:
|
|
354
|
+
_remove(target)
|
|
355
|
+
except OSError as exc:
|
|
356
|
+
raise click.ClickException(
|
|
357
|
+
f"could not remove {target}: {exc.strerror or exc}"
|
|
358
|
+
) from None
|
|
359
|
+
click.echo(f"removed {target}")
|
|
360
|
+
removed += 1
|
|
361
|
+
|
|
362
|
+
if not removed:
|
|
363
|
+
click.echo("nothing to remove")
|
|
364
|
+
|
|
365
|
+
@skill.command("status")
|
|
366
|
+
@json_option
|
|
367
|
+
def status_command(json_output: bool) -> None:
|
|
368
|
+
"""Show every known location and whether the skill is installed."""
|
|
369
|
+
payload = {
|
|
370
|
+
"skill": name,
|
|
371
|
+
"locations": _status_rows(Path.home(), name),
|
|
372
|
+
}
|
|
373
|
+
emit(payload, json_output=json_output, human=_status_lines)
|
|
374
|
+
|
|
375
|
+
return skill
|