Skip to content

Commit af14f75

Browse files
authored
feat: Add shiny skills CLI subcommand (list/get/path) (#2343)
1 parent 56bb1b3 commit af14f75

5 files changed

Lines changed: 174 additions & 2 deletions

File tree

.claude/references/agent-skills.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ The shiny package ships [Agent Skills](https://agentskills.io) under
55
following the [Agent Skills specification](https://agentskills.io/specification).
66
They are **package data**: they ship in the wheel and are discovered by
77
installers such as [library-skills](https://library-skills.io) (which symlinks
8-
them into a project's `.agents/skills/` or `.claude/skills/`). A `shiny skills
9-
list|get` CLI subcommand is planned as a zero-dependency way to read them.
8+
them into a project's `.agents/skills/` or `.claude/skills/`). The `shiny
9+
skills list|get|path` CLI subcommands (implemented in `shiny/_main_skills.py`)
10+
are a zero-dependency way to read them: `get <name>` prints the SKILL.md, and
11+
`path <name>` prints the skill's directory so supporting files
12+
(`references/`, `scripts/`) can be read from the installed package.
1013

1114
**Audience:** coding agents *using* shiny to build, test, and debug apps — not
1215
contributors to shiny itself. Contributor-facing guidance belongs in

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121

2222
* The shiny package now ships [Agent Skills](https://agentskills.io) under `shiny/.agents/skills/` (the [library-skills](https://library-skills.io) convention), starting with a `debugging` skill that teaches coding agents to inspect running apps via test mode, `shiny.testmode.export_test_values()`, and the test snapshot endpoint. (#2339)
2323

24+
* Added a `shiny skills` CLI command group to discover the Agent Skills bundled in the shiny package: `shiny skills list` shows each skill's name and description, `shiny skills get <name>` prints its `SKILL.md` to stdout, and `shiny skills path <name>` prints the skill's directory (for reading its `references/` and `scripts/`). (#2340)
25+
2426
* Added `offcanvas()` for creating sliding Bootstrap Offcanvas panels that appear from a viewport edge. Panels can be triggered by a UI element, revealed programmatically with `show_offcanvas()`, or controlled by id with `hide_offcanvas()` and `toggle_offcanvas()`. (#2279)
2527

2628
### Improvements

shiny/_main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from . import __version__, _autoreload, _launchbrowser, _static, _utils
2323
from ._docstring import no_example
24+
from ._main_skills import skills
2425
from ._uvicorn import (
2526
ReloadArgs,
2627
_run_uvicorn,
@@ -38,6 +39,9 @@ def main() -> None:
3839
pass
3940

4041

42+
main.add_command(skills)
43+
44+
4145
stop_shortcut = "Ctrl+C"
4246

4347
RELOAD_INCLUDES_DEFAULT = (

shiny/_main_skills.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from pathlib import Path
5+
6+
import click
7+
8+
SKILLS_DIR = Path(__file__).parent / ".agents" / "skills"
9+
"""Location of the Agent Skills bundled in the shiny package."""
10+
11+
12+
def _available_skills() -> list[Path]:
13+
if not SKILLS_DIR.is_dir():
14+
return []
15+
return sorted(
16+
p for p in SKILLS_DIR.iterdir() if p.is_dir() and (p / "SKILL.md").is_file()
17+
)
18+
19+
20+
def _skill_description(skill_dir: Path) -> str:
21+
"""Extract the one-line `description:` from a SKILL.md's YAML frontmatter."""
22+
text = (skill_dir / "SKILL.md").read_text()
23+
if not text.startswith("---\n"):
24+
return ""
25+
frontmatter = text.split("---", 2)[1]
26+
for line in frontmatter.splitlines():
27+
if line.startswith("description:"):
28+
return line.removeprefix("description:").strip()
29+
return ""
30+
31+
32+
def _find_skill(name: str) -> Path:
33+
"""Return the directory of the named skill, or raise a helpful error."""
34+
skill_dirs = _available_skills()
35+
if not skill_dirs:
36+
raise click.ClickException(
37+
"No skills are bundled in this installation of shiny."
38+
)
39+
skill_dir = next((p for p in skill_dirs if p.name == name), None)
40+
if skill_dir is None:
41+
available = ", ".join(p.name for p in skill_dirs)
42+
raise click.ClickException(
43+
f"Unknown skill {name!r}. Available skills: {available}"
44+
)
45+
return skill_dir
46+
47+
48+
@click.group(
49+
"skills",
50+
help="""List and read the Agent Skills bundled in the shiny package.
51+
52+
Agent Skills are reference docs that teach coding agents how to use shiny's
53+
public APIs (debugging, testing, etc.). They ship inside the package under
54+
`shiny/.agents/skills/`.
55+
""",
56+
)
57+
def skills() -> None:
58+
pass
59+
60+
61+
@skills.command("list", help="List the bundled Agent Skills.")
62+
def skills_list() -> None:
63+
skill_dirs = _available_skills()
64+
if not skill_dirs:
65+
print("No skills are bundled in this installation of shiny.")
66+
return
67+
width = max(len(p.name) for p in skill_dirs)
68+
for skill_dir in skill_dirs:
69+
print(f"{skill_dir.name:<{width}} {_skill_description(skill_dir)}")
70+
71+
72+
@skills.command("get", help="Print a bundled Agent Skill's SKILL.md to stdout.")
73+
@click.argument("name", type=str)
74+
def skills_get(name: str) -> None:
75+
sys.stdout.write((_find_skill(name) / "SKILL.md").read_text())
76+
77+
78+
@skills.command(
79+
"path",
80+
help="""Print the directory of a bundled Agent Skill.
81+
82+
Use this to read a skill's supporting files (`references/`, `scripts/`,
83+
`assets/`) directly from the installed package.
84+
""",
85+
)
86+
@click.argument("name", type=str)
87+
def skills_path(name: str) -> None:
88+
print(_find_skill(name))

tests/pytest/test_main_skills.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from click.testing import CliRunner
5+
6+
from shiny import _main_skills
7+
from shiny._main import main
8+
9+
SKILLS_DIR = Path(_main_skills.__file__).parent / ".agents" / "skills"
10+
11+
12+
def test_skills_list_shows_names_and_descriptions() -> None:
13+
result = CliRunner().invoke(main, ["skills", "list"])
14+
15+
assert result.exit_code == 0
16+
assert "debugging" in result.output
17+
# The one-line description from the skill's frontmatter is shown
18+
debugging_line = next(
19+
line for line in result.output.splitlines() if "debugging" in line
20+
)
21+
assert "test mode" in debugging_line or "debugging a Shiny" in debugging_line
22+
23+
24+
def test_skills_get_prints_skill_md() -> None:
25+
result = CliRunner().invoke(main, ["skills", "get", "debugging"])
26+
27+
assert result.exit_code == 0
28+
expected = (SKILLS_DIR / "debugging" / "SKILL.md").read_text()
29+
assert result.output == expected
30+
31+
32+
def test_skills_get_unknown_name_lists_available_skills() -> None:
33+
result = CliRunner().invoke(main, ["skills", "get", "does-not-exist"])
34+
35+
assert result.exit_code != 0
36+
assert "does-not-exist" in result.output
37+
assert "debugging" in result.output
38+
39+
40+
def test_skills_path_prints_skill_directory() -> None:
41+
result = CliRunner().invoke(main, ["skills", "path", "debugging"])
42+
43+
assert result.exit_code == 0
44+
assert result.output == f"{SKILLS_DIR / 'debugging'}\n"
45+
assert Path(result.output.strip()).is_absolute()
46+
47+
48+
def test_skills_path_unknown_name_lists_available_skills() -> None:
49+
result = CliRunner().invoke(main, ["skills", "path", "does-not-exist"])
50+
51+
assert result.exit_code != 0
52+
assert "does-not-exist" in result.output
53+
assert "debugging" in result.output
54+
55+
56+
def test_skills_list_with_empty_skills_dir(
57+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
58+
) -> None:
59+
monkeypatch.setattr(_main_skills, "SKILLS_DIR", tmp_path)
60+
61+
result = CliRunner().invoke(main, ["skills", "list"])
62+
63+
assert result.exit_code == 0
64+
assert "No skills" in result.output
65+
66+
67+
def test_skills_get_with_missing_skills_dir(
68+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
69+
) -> None:
70+
monkeypatch.setattr(_main_skills, "SKILLS_DIR", tmp_path / "nope")
71+
72+
result = CliRunner().invoke(main, ["skills", "get", "debugging"])
73+
74+
assert result.exit_code != 0
75+
assert "No skills" in result.output

0 commit comments

Comments
 (0)