Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,9 @@ jobs:
- run: npm ci && npm run build
- run: pip install -e ".[dev,lonboard]"
- run: pytest -q
# The agent-skill API reference is generated from widget traits and
# committed; fail if it's stale (regenerate with `npm run skill:gen`).
- name: Skill reference is up to date
run: |
npm run skill:gen
git diff --exit-code src/manywidgets/skill/references/widgets-api.md
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,9 @@ docs/plugin.mjs
# (scripts/build_widget_docs.py) — build artifacts, not source.
docs/widgets/*.ipynb

# Agent tooling: the skill ships in the package (src/manywidgets/skill/) and is
# installed locally with `manywidgets install-skill` — don't commit the copy.
.claude/

# OS
.DS_Store
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ Binder(source=slider, source_field="value",
multiplier=100, offset=200)
```

## Agent skill

manywidgets ships an [agent skill](src/manywidgets/skill/SKILL.md) so coding
agents can help you build widgets and dashboards. Install it into a location your
agent discovers:

```bash
manywidgets install-skill # ./.claude/skills/manywidgets/ (this project)
manywidgets install-skill --user # ~/.claude/skills/manywidgets/ (all projects)
manywidgets install-skill --path DIR # anywhere else (other agents)
```

The skill (an entrypoint plus `references/` on widget API, usage, and authoring)
travels inside the wheel, so it always matches the installed version. The
per-widget API reference is generated from widget traits — never hand-edited.

## How it works / design

Every widget extends a thin `BaseWidget` (auto-assigns a stable `widget_id`) and
Expand Down Expand Up @@ -94,7 +110,10 @@ Each widget owns its docs in `src/manywidgets/<name>/doc.md` (prose + a
`{code-cell}` example + an `{api-table}` placeholder). `npm run docs:gen` builds
`docs/widgets/<name>.ipynb` from those — auto-generating the API table from trait
introspection — so the per-widget pages are **generated build artifacts**
(gitignored), not hand-maintained. Build and view:
(gitignored), not hand-maintained. The agent skill's API reference is generated
the same way (`npm run skill:gen` → `src/manywidgets/skill/references/widgets-api.md`,
which **is** committed and CI-checked for drift) — regenerate it after changing
any widget's traits. Build and view:

```bash
# lonboard + geopandas + pyarrow are only needed for the lonboard interop example
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"test": "vitest run",
"test:watch": "vitest",
"docs:gen": "python scripts/build_widget_docs.py",
"skill:gen": "python scripts/build_skill_reference.py",
"serve": "python -m http.server -d docs/_build/html 3030",
"clean": "rm -rf src/manywidgets/*/dist"
},
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ dev = [
"ipykernel",
]

[project.scripts]
manywidgets = "manywidgets.__main__:main"

[project.urls]
Homepage = "https://github.com/developmentseed/manywidgets"
Repository = "https://github.com/developmentseed/manywidgets"
Expand Down
151 changes: 151 additions & 0 deletions scripts/build_skill_reference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Generate the agent-skill API reference from widget trait introspection.

Emits ``src/manywidgets/skill/references/widgets-api.md`` — a single, grouped
catalog with a per-widget trait table and a derived constructor signature. This
reuses the same introspection the docs builder uses (``public_traits`` /
``api_table`` / ``class_for`` from ``build_widget_docs``), so the reference can
never drift from the code.

Unlike the per-widget doc notebooks (gitignored build artifacts), this file is
**committed** — it's small static markdown, and committing guarantees it ships
inside the wheel (so ``manywidgets install-skill`` works offline and the
reference always matches the installed version's API).

Run: python scripts/build_skill_reference.py (with manywidgets importable)
CI regenerates this and fails on a dirty diff, so keep it committed and current.
"""

from __future__ import annotations

import pathlib

import traitlets

# Sibling module in scripts/; sys.path[0] is this script's dir when run directly.
from build_widget_docs import api_table, class_for, public_traits

ROOT = pathlib.Path(__file__).resolve().parent.parent
WIDGETS_SRC = ROOT / "src" / "manywidgets"
OUT = ROOT / "src" / "manywidgets" / "skill" / "references" / "widgets-api.md"

# Display grouping for the catalog. Widget dir names -> section. Any widget dir
# discovered but not listed here lands in "Other" so new widgets never vanish.
GROUPS: list[tuple[str, list[str]]] = [
("Charts & displays", ["chart", "stat", "number_display", "text", "legend"]),
("Input controls", ["slider", "range_slider", "dropdown", "toggle", "button", "number_input"]),
("Layout containers", ["row", "column", "grid"]),
("Linking", ["binder"]),
("Lonboard interop", ["layer_toggle", "layer_filter", "filter_binder"]),
]

# Containers take their children positionally as well as via children=[...].
CONTAINERS = {"row", "column", "grid"}


def signature(name: str, cls) -> str:
"""A keyword-arg constructor signature derived from the public traits.

No-default traits come first so the rendered call is valid Python ordering
(required-looking args before defaulted kwargs).
"""
required, defaulted = [], []
for tname, tr in public_traits(cls):
if tname == "widget_id":
continue
dv = tr.default_value
if dv is traitlets.Undefined:
required.append(tname)
else:
defaulted.append(f"{tname}={dv!r}")
cls_name = "".join(p.capitalize() for p in name.split("_"))
return f"{cls_name}({', '.join(required + defaulted)})"


def discover() -> dict[str, object]:
"""dir name -> widget class, for every widget dir that imports."""
found = {}
for doc_path in sorted(WIDGETS_SRC.rglob("doc.md")):
name = doc_path.parent.name
cls = class_for(name)
if cls is None:
print(f"skip {name} (class not importable — is the [lonboard] extra installed?)")
continue
found[name] = cls
return found


def main() -> int:
found = discover()
grouped = {g: [] for g, _ in GROUPS}
grouped["Other"] = []
placed = set()
for group, names in GROUPS:
for name in names:
if name in found:
grouped[group].append(name)
placed.add(name)
for name in found:
if name not in placed:
grouped["Other"].append(name)

lines = [
"# manywidgets — widget API reference",
"",
"<!-- GENERATED by scripts/build_skill_reference.py from widget traits. Do not edit by hand. -->",
"",
"Every widget is constructed with its traits as keyword arguments "
"(e.g. `Slider(min=0, max=10, value=5)`). Layout containers (`Row`, "
"`Column`, `Grid`) also accept children positionally: `Row(a, b)`.",
"Display a widget by leaving it as the last expression in a notebook cell.",
"",
"## Catalog",
"",
]
for group, _ in GROUPS:
if grouped[group]:
cls_names = ", ".join(
"`" + "".join(p.capitalize() for p in n.split("_")) + "`"
for n in grouped[group]
)
lines.append(f"- **{group}:** {cls_names}")
if grouped["Other"]:
cls_names = ", ".join(
"`" + "".join(p.capitalize() for p in n.split("_")) + "`"
for n in grouped["Other"]
)
lines.append(f"- **Other:** {cls_names}")
lines.append("")

for group, _ in GROUPS:
names = grouped[group]
if not names:
continue
lines.append(f"## {group}")
lines.append("")
for name in names:
cls = found[name]
cls_name = "".join(p.capitalize() for p in name.split("_"))
lines.append(f"### `{cls_name}`")
lines.append("")
doc = (cls.__doc__ or "").strip().split("\n\n")[0].strip()
if doc:
lines.append(doc)
lines.append("")
lines.append("```python")
lines.append(signature(name, cls))
lines.append("```")
lines.append("")
if name in CONTAINERS:
lines.append("Also: `%s(child1, child2, ...)` — children passed positionally." % cls_name)
lines.append("")
lines.append(api_table(cls))
lines.append("")

OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text("\n".join(lines).rstrip() + "\n")
print(f"wrote {OUT.relative_to(ROOT)} ({len(found)} widgets)")
return 0


if __name__ == "__main__":
raise SystemExit(main())
107 changes: 107 additions & 0 deletions src/manywidgets/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""``manywidgets`` command-line entry point.

Currently exposes a single subcommand, ``install-skill``, which copies the
bundled agent skill (``manywidgets/skill/``) into a location your coding agent
discovers. Claude Code looks in ``.claude/skills/`` (project) and
``~/.claude/skills/`` (personal), but the skill is plain Markdown — use
``--path`` to drop it wherever another agent expects it.

manywidgets install-skill # ./.claude/skills/manywidgets/
manywidgets install-skill --user # ~/.claude/skills/manywidgets/
manywidgets install-skill --path DIR # DIR/manywidgets/
manywidgets install-skill --force # overwrite an existing copy
"""

from __future__ import annotations

import argparse
import pathlib
import shutil
import sys
from importlib import resources


def _bundled_skill_dir() -> pathlib.Path:
"""Path to the skill files shipped inside the installed package."""
return pathlib.Path(str(resources.files("manywidgets") / "skill"))


def _resolve_target(args: argparse.Namespace) -> pathlib.Path:
"""Where the ``manywidgets/`` skill folder should be written."""
if args.path:
base = pathlib.Path(args.path).expanduser()
elif args.user:
base = pathlib.Path.home() / ".claude" / "skills"
else:
base = pathlib.Path.cwd() / ".claude" / "skills"
return base / "manywidgets"


def install_skill(args: argparse.Namespace) -> int:
src = _bundled_skill_dir()
if not (src / "SKILL.md").is_file():
print(
f"error: bundled skill not found at {src}. "
"Reinstall manywidgets, or run from a source checkout after "
"`npm run skill:gen`.",
file=sys.stderr,
)
return 1

dest = _resolve_target(args)
if dest.exists():
if not args.force:
print(
f"error: {dest} already exists. Re-run with --force to overwrite.",
file=sys.stderr,
)
return 1
shutil.rmtree(dest)

dest.parent.mkdir(parents=True, exist_ok=True)
# Copy only the skill content (SKILL.md + references/*.md), not stray files.
shutil.copytree(
src,
dest,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
)
print(f"Installed manywidgets skill to {dest}")
return 0


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="manywidgets")
sub = parser.add_subparsers(dest="command", required=True)

p = sub.add_parser(
"install-skill",
help="Copy the bundled agent skill into a discoverable location.",
description=install_skill.__doc__,
)
where = p.add_mutually_exclusive_group()
where.add_argument(
"--user",
action="store_true",
help="Install to ~/.claude/skills/ instead of the current project.",
)
where.add_argument(
"--path",
metavar="DIR",
help="Install under an arbitrary directory (for agents other than Claude).",
)
p.add_argument(
"--force",
action="store_true",
help="Overwrite an existing installation.",
)
p.set_defaults(func=install_skill)
return parser


def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading