Files
Nico Bellante d84e3344da Replace developing-with-streamlit with meta-skill for bundled skill discovery (#26)
## Summary

Replaces the old `developing-with-streamlit` skill (21 sub-skills + 17
templates) with a single lightweight **meta-skill** that discovers
Streamlit's bundled skills inside the pip package (>=1.57). One
user-level install works across every project; the discovered skills
always match each project's pinned Streamlit version.

Net diff: **+1,171 / −12,738** across 99 files.

## The contract

`developing-with-streamlit/scripts/discover.py` is the actual logic —
stdlib-only Python, cross-platform via `pathlib` + the `venv` module's
Windows/POSIX awareness. It detects the active Python interpreter
(`$VIRTUAL_ENV` → `./.venv` → `../.venv` → `<git-root>/.venv` →
`$CONDA_PREFIX` → `pipenv` if `Pipfile` → `poetry` if `poetry.lock` →
`pdm` if `pdm.lock` → `uv` if `uv.lock` → system), runs `import
streamlit; print(streamlit.__path__[0])`, and emits one of six exit
codes:

| Exit | Meaning | Agent action |
|---|---|---|
| 0 | Bundled SKILL.md found | Read printed path, follow its routing |
| 1 | Streamlit not installed | Confirm with user, then install with the
**single** package-manager-matched command in the error (`poetry add
streamlit` for poetry, `uv add streamlit` for uv, `<venv-python> -m pip
install streamlit` for venv, etc.) — no buffet |
| 2 | Streamlit < 1.57 | Suggest `pip install --upgrade streamlit` for
version-matched bundled skills; fall back to
`docs.streamlit.io/llms-full.txt` if user can't upgrade |
| 3 | No Python interpreter | Ask user to install Python 3.10+ (suggest
`uv` as the easy path) |
| 4 | Bundled skills exist but expected sub-path missing | Read listed
skills directly |
| 5 | Invalid argument | Fix invocation |

`SKILL.md` is a thin wrapper that documents this contract and tells the
agent to invoke `python <SKILL_DIR>/scripts/discover.py --project-dir
<USER_PROJECT_DIR>`. The `--project-dir` arg makes resolution
deterministic regardless of the agent's CWD at invocation time.

`detect_interpreter()` returns `(cmd, tag)` where `tag` identifies which
detection branch fired. `install_advice(cmd, tag)` maps the tag to one
matching install command, so a poetry project's exit-1 message points at
`poetry add streamlit` instead of dumping pip + uv + poetry + pipenv +
conda and asking the agent to guess.

Why a script instead of prose: same logic runs in production AND in
tests, so no parallel reference impl can drift. Pure-prose meta-skills
need LLM-in-the-loop evals to validate; this gets the bulk of realistic
failure modes from cheap pytest at zero LLM cost — modulo the
description-triggering question, which only LLM eval can answer.

## Tier 1 test matrix

19 pytest tests (26 cases including parametrized) in
`tests/discovery/test_discovery.py`. Each E2E test creates an isolated
`tmp_path` venv via the stdlib `venv` module, installs Streamlit fresh,
runs `discover.py`, and asserts the contract. Coverage:

- **Detection priority**: each interpreter source in our priority order,
including `<git-root>/.venv` (helpful for deep monorepo layouts)
- **Conflict**: `$VIRTUAL_ENV` wins over `./.venv`
- **Fallbacks**: missing-Streamlit (exit 1), pre-1.57 (exit 2),
upstream-restructured (exit 4)
- **Invocation contract**: `--project-dir` argument from outside the
project
- **Tool-marker detection**: `Pipfile` (pipenv), `poetry.lock` (poetry),
`pdm.lock` (pdm), `uv.lock` (uv)
- **uv branch isn't too eager**: doesn't fire on bare `pyproject.toml`
without `uv.lock`
- **Tool-aware install advice**: `install_advice` parametrized over all
10 tags (virtual-env, venv-local, venv-parent, venv-git-root, conda,
pipenv, poetry, pdm, uv, system) — guarantees the per-tag mapping
doesn't regress

Negative-test verified: temporarily reversing priority order in
`discover.py` causes `test_priority_venv_over_local` to fail with a
clear diagnostic; reverting the `uv.lock` marker causes
`test_uv_no_lockfile` to fail similarly. Both prove the tests catch the
regressions they're designed for.

CI runs on `ubuntu-latest` AND `windows-latest`, on PR + push to main +
weekly cron. Tests install whatever `streamlit` is currently latest on
PyPI — failures here are an honest signal that something broke, either
in our code or in upstream's bundled-skill layout.

### Latest passing run

[Run
25827312532](https://github.com/streamlit/agent-skills/actions/runs/25827312532)
on commit `7fd4fae`:

| Job | Result | Duration |
|---|---|---|
|
[`ubuntu-latest`](https://github.com/streamlit/agent-skills/actions/runs/25827312532/job/75883639893)
| 26 passed | 4m 33s |
|
[`windows-latest`](https://github.com/streamlit/agent-skills/actions/runs/25827312532/job/75883639903)
| 26 passed | 9m 13s |

All 26 cases pass on both runners — no skips. Every Windows-specific
concern (Scripts vs bin, `python.exe` vs `python`, path separators,
`Pipfile` / `poetry.lock` / `pdm.lock` / `uv.lock` markers) is
empirically validated. Each E2E test pins the resolved path to its own
venv via `assert_resolves_bundled(..., inside=...)`, so a
wrong-but-valid Streamlit install elsewhere on the runner can't silently
satisfy the assertion.

## End-to-end verification (LLM-in-the-loop)

Hands-on smoke test in a fresh Claude Code v2.1.128 session — done by me
before opening this for review, not by an automated script.

**Setup:** Built a `streamlit==1.57.0` venv at
`/tmp/test-streamlit-install-2/`, installed the skill at
`~/.claude/skills/developing-with-streamlit/`. Started Claude Code in
the project directory with no prior context, no `CLAUDE.md`, no skill
pre-selected.

**Prompt:**

> *I want to add a small KPI dashboard to a new app.py in this directory
— three metric cards showing total revenue, active users, and conversion
rate. Use placeholder values for now. Make it look polished.*

**Observed (full trajectory captured in the session JSONL — 14 tool
calls):**

1. Description match → `Skill(developing-with-streamlit)` fires
automatically. The prompt never names the skill or mentions `discover`.
2. `Bash` runs `discover.py --project-dir <project>` with **no
permission prompt** — the `allowed-tools` frontmatter pre-approves the
bundled script.
3. Stdout returns the bundled `SKILL.md` path inside the venv.
4. `Read` bundled SKILL.md, then `references/dashboards.md` and
`references/design.md`.
5. `ls` + `Read` of the `dashboard-metrics` template.
6. `Write` `app.py` using bundled patterns: `st.metric(border=True)`,
horizontal container, Material icons, sparkline trends.
7. Step 4 of the bundled SKILL.md respected — agent offered to run the
app rather than auto-running.

Whole chain held end-to-end. No skill misroutes, no silent fallthroughs,
no freelancing of Streamlit code without first reading the bundled refs.

## Acknowledged gaps

- **Multi-env disambiguation** — user with both conda + venv pinning
different Streamlit versions gets whichever the priority order picks.
Acceptable without a `cortex env detect`-style CLI.
- **Cross-agent `allowed-tools` support** — the pre-approval works in
Claude Code but is ignored by Cursor / Copilot / Gemini / Codex. They
fall back to their own permission prompts, which is the safe default —
just slightly more friction on first invocation in those agents.
- **Cross-agent end-to-end validation** — the smoke test above was
Claude Code only. Other agents follow the same Agent Skills spec and
should work via `npx skills add`, but individual agents haven't been
verified hands-on.

## Manual verification

All three confirmed by the cold-start hands-on test above:

- [x] Description's trigger phrases auto-fire the skill (no prompt
mentioned `developing-with-streamlit` or `discover`).
- [x] Agent invokes `scripts/discover.py` rather than winging it from
prose.
- [x] `allowed-tools` frontmatter pre-approves the bundled `discover.py`
(no bash permission prompt on first invocation in Claude Code).
2026-05-13 15:06:46 -07:00

298 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Discover the Streamlit package's bundled agent-skills SKILL.md.
Usage:
python scripts/discover.py [--project-dir PATH]
When --project-dir is given, the script resolves `.venv`, `../.venv`,
`Pipfile`, `poetry.lock`, `pdm.lock`, and `uv.lock` relative to that path (so
its checks land on the user's project rather than on the script's installed
location).
Exit codes:
0 - success; prints the absolute path to the bundled SKILL.md on stdout.
1 - Streamlit is not installed in the detected interpreter.
2 - Streamlit is installed but predates bundled skills (no .agents/skills/).
3 - no usable Python interpreter was found.
4 - .agents/skills/ exists but the expected developing-with-streamlit/SKILL.md
is missing from the documented sub-path (likely upstream restructured).
The agent should read the listed available skills directly.
5 - invalid script argument.
On non-zero exit, a human-readable "ERROR:" block is printed on stderr.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional, Tuple
def find_venv_python(venv_root: Path) -> Optional[Path]:
"""Return the venv's Python executable, cross-platform.
POSIX venvs put it at bin/python; Windows venvs put it at Scripts/python.exe.
"""
for candidate in (
venv_root / "bin" / "python",
venv_root / "Scripts" / "python.exe",
):
if candidate.is_file():
return candidate
return None
def find_git_root(start: Path) -> Optional[Path]:
"""Walk up from `start` looking for a `.git` directory or file.
Returns the directory containing `.git` (the repo root), or None if no
git repository is found above `start`. Handles the worktree case where
`.git` is a file pointing at the real repo dir.
"""
for ancestor in [start, *start.parents]:
if (ancestor / ".git").exists():
return ancestor
return None
def detect_interpreter(project_dir: Path) -> Optional[Tuple[List[str], str]]:
"""Pick the right Python interpreter, in documented priority order.
Returns ``(cmd, tag)`` where ``cmd`` is the command for ``subprocess.run``
and ``tag`` identifies which detection branch fired (``virtual-env``,
``venv-local``, ``venv-parent``, ``venv-git-root``, ``conda``, ``pipenv``,
``poetry``, ``pdm``, ``uv``, or ``system``). The tag lets callers give
targeted install advice on exit 1 instead of a buffet of unrelated
package-manager commands.
"""
venv = os.environ.get("VIRTUAL_ENV")
if venv:
py = find_venv_python(Path(venv))
if py:
return [str(py)], "virtual-env"
py = find_venv_python(project_dir / ".venv")
if py:
return [str(py)], "venv-local"
py = find_venv_python(project_dir.parent / ".venv")
if py:
return [str(py)], "venv-parent"
# Walk up to the git repo root and look for a `.venv` there. Helpful for
# monorepos where the project's venv lives at repo root but the agent's
# cwd / --project-dir points deep into a subdirectory.
git_root = find_git_root(project_dir)
if (
git_root is not None
and git_root != project_dir
and git_root != project_dir.parent
):
py = find_venv_python(git_root / ".venv")
if py:
return [str(py)], "venv-git-root"
conda = os.environ.get("CONDA_PREFIX")
if conda:
py = find_venv_python(Path(conda))
if py:
return [str(py)], "conda"
if shutil.which("pipenv") and (project_dir / "Pipfile").is_file():
return ["pipenv", "run", "python"], "pipenv"
if shutil.which("poetry") and (project_dir / "poetry.lock").is_file():
return ["poetry", "run", "python"], "poetry"
if shutil.which("pdm") and (project_dir / "pdm.lock").is_file():
return ["pdm", "run", "python"], "pdm"
if shutil.which("uv") and (project_dir / "uv.lock").is_file():
return ["uv", "run", "--quiet", "python"], "uv"
for name in ("python3", "python"):
if shutil.which(name):
return [name], "system"
return None
def install_advice(cmd: List[str], tag: str) -> str:
"""Return the package-manager-appropriate install command for the
detected interpreter.
``detect_interpreter`` already chose a branch; we know which tool to
suggest. Dumping every install command and asking the agent to "match
the tool for your project" is how a poetry project gets a stray
``pip install streamlit`` outside the lockfile.
"""
if tag in {"virtual-env", "venv-local", "venv-parent", "venv-git-root"}:
# Use the venv's own python to run pip — independent of activation
# state on the user's shell.
return f"{cmd[0]} -m pip install streamlit"
if tag == "conda":
return "conda install -c conda-forge streamlit"
if tag == "pipenv":
return "pipenv install streamlit"
if tag == "poetry":
return "poetry add streamlit"
if tag == "pdm":
return "pdm add streamlit"
if tag == "uv":
return "uv add streamlit"
# tag == "system" (or unknown — defensive)
return (
f"{cmd[0]} -m pip install streamlit\n"
" (better: create a project venv first with "
"`python -m venv .venv && source .venv/bin/activate`)"
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Discover the bundled developing-with-streamlit SKILL.md.",
)
parser.add_argument(
"--project-dir",
default=None,
help="Absolute path to the user's project directory. Defaults to cwd.",
)
try:
args = parser.parse_args()
except SystemExit as e:
return 5 if e.code else 0
if args.project_dir is not None:
project_dir = Path(args.project_dir)
if not project_dir.is_dir():
print(
f"ERROR: --project-dir is not a directory: {project_dir}",
file=sys.stderr,
)
return 5
else:
project_dir = Path.cwd()
project_dir = project_dir.resolve()
detection = detect_interpreter(project_dir)
if detection is None:
print(
"ERROR: No Python interpreter found.\n"
"Install Python 3.10+ (the easiest path is `uv` — see https://docs.astral.sh/uv/),\n"
"then install Streamlit (pip install streamlit) and re-run.",
file=sys.stderr,
)
return 3
cmd, tag = detection
py_display = " ".join(cmd)
probe = "import streamlit; print(streamlit.__path__[0])"
try:
result = subprocess.run(
[*cmd, "-c", probe],
capture_output=True,
text=True,
cwd=project_dir,
timeout=30,
)
except subprocess.TimeoutExpired:
print(
f"ERROR: import streamlit timed out (interpreter: {py_display})",
file=sys.stderr,
)
return 1
except FileNotFoundError:
print(
f"ERROR: detected interpreter not found on PATH: {py_display}",
file=sys.stderr,
)
return 3
if result.returncode != 0:
combined = (result.stderr or "") + (result.stdout or "")
if "ModuleNotFoundError" in combined:
advice = install_advice(cmd, tag)
extra = ""
if tag == "system":
# No env-manager artifact found. The user might still have
# one (hatch, pyenv-virtualenv, an unactivated conda env)
# we couldn't auto-detect.
extra = (
"\n\nIf your project uses an environment manager we did not\n"
"auto-detect (hatch, pyenv-virtualenv, an unactivated conda env),\n"
"ACTIVATE it first so the right Python is found, then re-run."
)
print(
"ERROR: Streamlit is not installed in the detected Python environment.\n"
f"Interpreter: {py_display}\n"
f"Detected via: {tag}\n"
"\n"
f"Install with: {advice}\n"
"\n"
f"Then re-run this script.{extra}",
file=sys.stderr,
)
return 1
print(
"ERROR: Failed to import streamlit.\n"
f"Interpreter: {py_display}\n"
"Output:\n"
f"{combined}",
file=sys.stderr,
)
return 1
streamlit_path = Path(result.stdout.strip()).resolve()
agents_skills_dir = streamlit_path / ".agents" / "skills"
primary_skill = agents_skills_dir / "developing-with-streamlit" / "SKILL.md"
if primary_skill.is_file():
print(primary_skill)
return 0
if agents_skills_dir.is_dir():
print(
"ERROR: Streamlit's bundled skills directory exists, but the expected\n"
"developing-with-streamlit/SKILL.md is missing from the documented sub-path.\n"
"This usually means upstream Streamlit reorganized the skill layout.\n"
"\n"
f"Streamlit path: {streamlit_path}\n"
f"Bundled skills directory: {agents_skills_dir}\n"
"Available entries:",
file=sys.stderr,
)
for entry in sorted(agents_skills_dir.iterdir()):
print(f" {entry.name}", file=sys.stderr)
print(
"\n"
"Read whichever skill best matches the user's task. If none match,\n"
"fall back to the complete Streamlit documentation:\n"
" https://docs.streamlit.io/llms-full.txt",
file=sys.stderr,
)
return 4
print(
f"ERROR: Streamlit is installed but predates bundled skills (< 1.57).\n"
f"Interpreter: {py_display}\n"
f"Streamlit path: {streamlit_path}\n"
"\n"
"For best results, upgrade to get version-matched bundled skills:\n"
" pip install --upgrade streamlit\n"
"\n"
"If upgrading isn't an option, fall back to the complete Streamlit\n"
"documentation (full API + guides, formatted for LLMs):\n"
" https://docs.streamlit.io/llms-full.txt",
file=sys.stderr,
)
return 2
if __name__ == "__main__":
sys.exit(main())