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/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
@@ -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