Skip to content

Commit 1ca5092

Browse files
authored
feat: Ship a debugging Agent Skill in the shiny package (#2341)
1 parent 661bdf6 commit 1ca5092

6 files changed

Lines changed: 353 additions & 1 deletion

File tree

.claude/references/agent-skills.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Bundled Agent Skills
2+
3+
The shiny package ships [Agent Skills](https://agentskills.io) under
4+
`shiny/.agents/skills/` — one directory per skill, each with a `SKILL.md`,
5+
following the [Agent Skills specification](https://agentskills.io/specification).
6+
They are **package data**: they ship in the wheel and are discovered by
7+
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.
10+
11+
**Audience:** coding agents *using* shiny to build, test, and debug apps — not
12+
contributors to shiny itself. Contributor-facing guidance belongs in
13+
`.claude/references/`, not here. If a topic serves both audiences, the bundled
14+
skill documents the public API and the reference file documents the internals.
15+
16+
## Layout
17+
18+
Per the spec, a skill is a directory containing at minimum a `SKILL.md`, plus
19+
three conventional optional directories:
20+
21+
```
22+
shiny/.agents/skills/
23+
<skill-name>/
24+
SKILL.md # required: frontmatter + instructions
25+
scripts/ # optional: self-contained runnable code
26+
references/ # optional: on-demand docs (loaded only when needed)
27+
assets/ # optional: templates, images, data files
28+
```
29+
30+
- Skill names are short topics: `debugging`, `testing`, `modules`, `express`, ...
31+
- Names are **unprefixed** (no `shiny-` prefix): installers namespace skills
32+
by package, and the description scopes the skill to Shiny for Python.
33+
- Keep everything in `SKILL.md` until a file earns its keep. Heavy reference
34+
material (100+ lines) goes in `references/` — agents load those on demand,
35+
so smaller focused files cost less context. Link with relative paths from
36+
the skill root (`references/REFERENCE.md`) and keep references one level
37+
deep — no chains of files pointing at files.
38+
39+
## Frontmatter
40+
41+
```yaml
42+
---
43+
name: <skill-name>
44+
description: <what the skill covers and when to use it>
45+
---
46+
```
47+
48+
Required fields (spec constraints):
49+
50+
- `name` — 1-64 characters; lowercase letters, numbers, and hyphens only; no
51+
leading, trailing, or consecutive hyphens; **must match the directory name**.
52+
- `description` — 1-1024 characters, non-empty. Covers both *what the skill
53+
does* and *when to use it*, with keywords agents would match on.
54+
55+
How to write the description (this is the only part of a skill loaded at
56+
startup — agents decide from it alone whether to activate the skill):
57+
58+
- Lead with a one-clause summary of what the skill covers, then "Use when…"
59+
listing triggering conditions, symptoms, and search phrases — including the
60+
*wrong* approach an agent might be about to take (e.g. "…or when tempted to
61+
add print statements or hidden outputs").
62+
- Do **not** summarize the skill's workflow or step-by-step process: agents
63+
that get the recipe from the description follow it and skip the body.
64+
- Third person. Descriptions across skills must not overlap: agents pick a
65+
skill by description alone, so two skills matching the same symptom means
66+
the wrong one gets loaded. When adding a skill, re-read the other
67+
descriptions and sharpen the boundaries.
68+
69+
Optional fields (`license`, `compatibility`, `metadata`, `allowed-tools`) are
70+
defined by the spec but usually unnecessary here: skills inherit the package's
71+
MIT license, and `compatibility` is only for skills with environment
72+
requirements beyond shiny itself (e.g. requires Playwright installed).
73+
74+
The name/directory match and description presence are enforced by
75+
`tests/pytest/test_packaging.py`.
76+
77+
## Body content
78+
79+
The body is loaded in full once a skill activates, so size it for progressive
80+
disclosure: keep `SKILL.md` under ~500 lines (well under 5000 tokens), moving
81+
detail to `references/`. Skills are reference docs, not tutorials. The shape
82+
that works:
83+
84+
1. **Overview** — the core principle in 1-3 sentences, including what NOT to
85+
do (the workaround the skill replaces).
86+
2. **Task-oriented sections** — one per job the agent came to do, each with a
87+
short runnable example against the **public API only** (imports included,
88+
copy-paste ready). One excellent example per pattern; no variations.
89+
3. **Quick-reference table** for enumerable facts (env vars, flags, marker
90+
strings).
91+
4. **Common mistakes** — concrete symptom → fix pairs, especially the
92+
non-obvious gotchas (e.g. module namespacing of ids, 404 when a feature
93+
flag is off).
94+
95+
Keep a skill focused on one topic and roughly 400-800 words. If a section
96+
outgrows that, it is probably a second skill. Prefer extending an existing
97+
skill over creating a new one; create a new directory only for a genuinely
98+
separate topic with its own triggering conditions.
99+
100+
Anything in `scripts/` must be self-contained (or clearly document its
101+
dependencies), emit helpful error messages, and handle edge cases — agents
102+
run these directly.
103+
104+
## Maintenance
105+
106+
- Bundled skills are **user-facing documentation**. When a PR changes an API
107+
that a skill documents, update the skill in the same PR — grep
108+
`shiny/.agents/skills/` for the API name as part of the change.
109+
- Skills describe behavior as shipped, not as planned: never document an API
110+
before it is merged.
111+
- Adding or materially changing a skill warrants a CHANGELOG entry.
112+
113+
## Validation
114+
115+
- `pytest tests/pytest/test_packaging.py` — checks the frontmatter contract
116+
and that every file under `shiny/.agents/` is matched by the
117+
`[tool.setuptools.package-data]` globs in `pyproject.toml`. The explicit
118+
`.agents/**` glob is required because `**` does not match hidden
119+
directories; without it skills silently drop out of the wheel.
120+
- The spec's reference validator checks frontmatter and naming conventions:
121+
`uvx --from skills-ref agentskills validate shiny/.agents/skills/<name>`
122+
(see [skills-ref](https://github.com/agentskills/agentskills/tree/main/skills-ref)).
123+
- To confirm against a real build:
124+
`uv build --wheel && unzip -l dist/shiny-*.whl | grep .agents`
125+
- For a new skill, do a before/after check: give a fresh agent (no skill) a
126+
task the skill targets and note what it reaches for; then repeat with the
127+
skill available. If the "after" run doesn't change the approach, the
128+
description isn't triggering or the body isn't teaching — fix before
129+
shipping.

CHANGELOG.md

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

2020
* Test-mode snapshot values can now be preprocessed before they are written to the snapshot, e.g. to scrub timestamps or temp paths: `input.set_snapshot_preprocess(id, fn)` (or `shiny.testmode.snapshot_preprocess_input()`) for inputs and `my_output.snapshot_preprocess(fn)` for outputs. Handlers may be synchronous or asynchronous. File inputs automatically scrub each file's `datapath` to its basename, matching Shiny for R. `export_test_values()` moved from `shiny.session` to the new `shiny.testmode` module. (#2282)
2121

22+
* 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)
23+
2224
* 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)
2325

2426
### Improvements

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ Before implementing a UI component or output renderer, read
107107
(file layout, exports, examples, controllers, tests) and the rules for pairing
108108
output components with renderers.
109109

110+
### Bundled Agent Skills
111+
112+
The shiny package ships Agent Skills under `shiny/.agents/skills/` — reference
113+
docs that teach coding agents how to use shiny's public APIs (debugging,
114+
testing, etc.). Treat them like user-facing documentation: when a change
115+
touches an API that a skill documents, update the skill in the same PR. Before
116+
adding or editing a skill, read `.claude/references/agent-skills.md` for the
117+
layout, frontmatter contract, content shape, scoping rules, and validation
118+
steps.
119+
110120
### Type Checking Notes
111121

112122
- Pyright is the primary type checker (not mypy)
@@ -163,3 +173,4 @@ handling before merging one.
163173
- **Reactive graph debugging**: Use `reactive.flush()` to force synchronous execution in tests
164174
- **Playwright timing**: Use `.expect_*()` methods which auto-wait; avoid manual `sleep()`
165175
- **Asset updates**: After running `make upgrade-html-deps`, verify theme preset files were updated
176+
- **Stale bundled skills**: When changing a public API, grep `shiny/.agents/skills/` for it — bundled Agent Skills document public APIs and must be updated in the same PR

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ build-backend = "setuptools.build_meta"
99
# `**` does not match hidden files (the package templates ship .gitignore files).
1010
include-package-data = false
1111

12+
# The `.agents/**` glob is needed because `**` does not match hidden
13+
# directories: Agent Skills ship inside the package under `shiny/.agents/skills/`
14+
# (the library-skills convention). Guarded by tests/pytest/test_packaging.py.
1215
[tool.setuptools.package-data]
13-
shiny = ["**", "**/.gitignore"]
16+
shiny = ["**", "**/.gitignore", ".agents/**"]
1417

1518
[tool.setuptools.packages.find]
1619
where = ["."]
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
---
2+
name: debugging
3+
description: Use when debugging a Shiny for Python (py-shiny) app or testing one - inspecting reactive/calc values in a running session, capturing current input/output state, exposing internal values to a test harness, writing snapshot tests, or when tempted to add print statements or hidden outputs to see server-side values.
4+
---
5+
6+
# Debugging Shiny for Python apps
7+
8+
## Overview
9+
10+
py-shiny has a built-in **test mode**: every session can serve a read-only JSON
11+
snapshot of its `input`, `output`, and `export` values over HTTP. Use it instead
12+
of print statements, hidden outputs, or websocket spelunking to observe
13+
server-side state.
14+
15+
## Enable test mode
16+
17+
- Env var: `SHINY_TESTMODE=1 shiny run app.py`
18+
- Or in code: `App(app_ui, server, test_mode=True)`
19+
- The pytest app-launch fixtures (`local_app`, `create_app_fixture`) enable it
20+
automatically.
21+
22+
When off, the snapshot endpoint returns 404 and the APIs below are cheap no-ops,
23+
so calls can be left in production code.
24+
25+
## Read a session snapshot
26+
27+
Each session serves `GET /session/{id}/dataobj/shinytest` returning
28+
`{"input": {...}, "output": {...}, "export": {...}}` with sorted keys.
29+
30+
**Getting the URL:** the session id only exists after the client connects — do
31+
not guess it or scrape it from the page. Obtain the URL through one of two
32+
paths:
33+
34+
1. **From the browser** (devtools console, browser automation) — ask the Shiny
35+
client, then GET the result from the page context (so any auth
36+
cookies/headers come along):
37+
38+
```js
39+
window.Shiny.shinyapp.getTestSnapshotBaseUrl({ fullUrl: true })
40+
// -> "http://127.0.0.1:8000/session/<id>/dataobj/shinytest?w=...&nonce=..."
41+
```
42+
43+
This is undefined until the app has connected; wait for it to exist before
44+
calling. (In Playwright, prefer `AppTestValues` below, which handles the
45+
waiting and fetching.)
46+
47+
2. **From server code** (inside the `server` function, where `session` is in
48+
scope) — `session.get_test_snapshot_url()` returns the relative URL with a
49+
cache-busting nonce; log it or surface it to wherever you need it.
50+
51+
Select blocks with query params: `?input=1` (whole block), `?output=a,b`
52+
(specific keys); no params returns all three blocks.
53+
54+
Values that fail to serialize appear as visible markers instead of erroring:
55+
`{"__shiny_serialization_error__": ...}`, output render errors as
56+
`{"__shiny_output_error__": ...}`, preprocessor failures as
57+
`{"__shiny_snapshot_preprocess_error__": ...}`.
58+
59+
## Expose internal reactive values: `export_test_values()`
60+
61+
To let a test harness read a `reactive.calc` or other internal value (do NOT
62+
render it into a hidden output):
63+
64+
```python
65+
from shiny import App, Inputs, Outputs, Session, reactive, ui
66+
from shiny.testmode import export_test_values
67+
68+
app_ui = ui.page_fluid(ui.input_slider("n", "n", 0, 100, 20))
69+
70+
def server(input: Inputs, output: Outputs, session: Session):
71+
@reactive.calc
72+
def doubled():
73+
return input.n() * 2
74+
75+
# Appears under "export" in the snapshot. No-op unless test mode is on.
76+
export_test_values(doubled=doubled, plus_one=lambda: doubled() + 1)
77+
78+
app = App(app_ui, server)
79+
```
80+
81+
Each value is a zero-argument callable, evaluated lazily (in a reactive
82+
isolate) when a snapshot is requested. Inside a module, export names are
83+
namespaced with the module's `ns` prefix. Re-registering a name overwrites it.
84+
85+
## Assert snapshot values in Playwright tests
86+
87+
```python
88+
from shiny.playwright import controller
89+
90+
app_values = controller.AppTestValues(page)
91+
app_values.expect_input("n", 20)
92+
app_values.expect_output("greeting", "Hello abc")
93+
app_values.expect_export("doubled", 40)
94+
snapshot = app_values.get() # full {"input", "output", "export"} dict
95+
```
96+
97+
Requires the app loaded in the page and test mode on (a `RuntimeError` with a
98+
fix hint is raised otherwise).
99+
100+
## Scrub unstable values from snapshots
101+
102+
Timestamps, temp paths, etc. make snapshots non-deterministic. Preprocess
103+
before they are written (sync or async functions both work):
104+
105+
- Inputs: `shiny.testmode.snapshot_preprocess_input("secret", lambda v: "<redacted>")`
106+
or `session.input.set_snapshot_preprocess(id, fn)`
107+
- Outputs: call `.snapshot_preprocess(fn)` on the `@render.*` object
108+
- File inputs automatically scrub each file's `datapath` to its basename
109+
110+
## Other debugging tools
111+
112+
| Need | Tool |
113+
|---|---|
114+
| Verbose framework logs | `SHINY_LOG_LEVEL=DEBUG shiny run app.py` |
115+
| Echo client/server websocket messages | `App(..., debug=True)` |
116+
| Auto-reload during development | `shiny run app.py --reload --launch-browser` |
117+
118+
## Common mistakes
119+
120+
- Hitting the snapshot endpoint without test mode enabled → 404. Set
121+
`SHINY_TESTMODE=1` or `App(test_mode=True)`.
122+
- Rendering values into hidden outputs just so tests can read them → use
123+
`export_test_values()` instead.
124+
- Expecting un-namespaced export names inside modules → exports are prefixed
125+
with the module namespace, like inputs and outputs.
126+
- Snapshot diffs churn on paths/timestamps → add a snapshot preprocessor
127+
instead of post-processing the JSON.

tests/pytest/test_packaging.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Guard that bundled Agent Skills stay covered by the wheel packaging globs.
2+
3+
Agent Skills ship as package data under ``shiny/.agents/skills/`` (the
4+
library-skills convention). Because ``**`` in setuptools package-data globs does
5+
not match hidden directories, the ``.agents`` tree needs its own glob in
6+
``pyproject.toml`` -- and nothing at build time fails if that glob is dropped,
7+
the files just silently disappear from the wheel. These tests replicate
8+
setuptools' matching (``glob.glob(os.path.join(src_dir, pattern),
9+
recursive=True)`` in ``setuptools.command.build_py``) against the declared
10+
patterns so a regression fails loudly here instead.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import glob
16+
import os
17+
from pathlib import Path
18+
19+
import pytest
20+
21+
tomllib = pytest.importorskip("tomllib") # Stdlib in Python 3.11+
22+
23+
REPO_ROOT = Path(__file__).parents[2]
24+
PACKAGE_DIR = REPO_ROOT / "shiny"
25+
SKILLS_DIR = PACKAGE_DIR / ".agents" / "skills"
26+
27+
28+
def package_data_files() -> set[Path]:
29+
"""Files shipped as `shiny` package data, per pyproject.toml globs."""
30+
with open(REPO_ROOT / "pyproject.toml", "rb") as f:
31+
pyproject = tomllib.load(f)
32+
patterns: list[str] = pyproject["tool"]["setuptools"]["package-data"]["shiny"]
33+
34+
matched: set[Path] = set()
35+
for pattern in patterns:
36+
for match in glob.glob(os.path.join(str(PACKAGE_DIR), pattern), recursive=True):
37+
if os.path.isfile(match):
38+
matched.add(Path(match))
39+
return matched
40+
41+
42+
def skill_dirs() -> list[Path]:
43+
return sorted(p for p in SKILLS_DIR.iterdir() if p.is_dir())
44+
45+
46+
def test_skills_directory_has_skills() -> None:
47+
assert SKILLS_DIR.is_dir()
48+
assert len(skill_dirs()) > 0
49+
50+
51+
def test_every_skill_has_a_skill_md() -> None:
52+
for skill_dir in skill_dirs():
53+
assert (skill_dir / "SKILL.md").is_file(), f"{skill_dir} is missing SKILL.md"
54+
55+
56+
def test_skill_files_are_covered_by_package_data_globs() -> None:
57+
shipped = package_data_files()
58+
skill_files = [p for p in SKILLS_DIR.rglob("*") if p.is_file()]
59+
assert len(skill_files) > 0
60+
missing = [p for p in skill_files if p not in shipped]
61+
assert not missing, (
62+
"Files under shiny/.agents/ are not matched by any "
63+
"[tool.setuptools.package-data] glob in pyproject.toml and would be "
64+
f"silently dropped from the wheel: {missing}"
65+
)
66+
67+
68+
def test_skill_frontmatter_has_name_and_description() -> None:
69+
# Minimal SKILL.md frontmatter contract (agentskills.io specification);
70+
# also what `shiny skills list` will surface.
71+
for skill_dir in skill_dirs():
72+
text = (skill_dir / "SKILL.md").read_text()
73+
assert text.startswith("---\n"), f"{skill_dir}: SKILL.md missing frontmatter"
74+
frontmatter = text.split("---", 2)[1]
75+
assert (
76+
f"name: {skill_dir.name}" in frontmatter
77+
), f"{skill_dir}: frontmatter `name` must match its directory name"
78+
assert (
79+
"description:" in frontmatter
80+
), f"{skill_dir}: frontmatter is missing `description`"

0 commit comments

Comments
 (0)