Skip to content

Commit a65ead7

Browse files
authored
Merge pull request #162 from posit-dev/fix-skill-include-manifest
fix: have skill include manifest JSON
2 parents f6336c7 + 99cee91 commit a65ead7

4 files changed

Lines changed: 86 additions & 42 deletions

File tree

great_docs/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@
227227
"skill": {
228228
"enabled": True,
229229
"file": None, # Path to a hand-written SKILL.md (overrides auto-generation)
230-
"well_known": True, # Also serve at /.well-known/skills/default/SKILL.md
230+
"well_known": True, # Also serve at /.well-known/agent-skills/{name}/SKILL.md + index.json
231231
"gotchas": [], # List of gotcha strings for the Gotchas section
232232
"best_practices": [], # List of best-practice strings
233233
"decision_table": [], # Manual rows: [{"need": "...", "use": "..."}]
@@ -567,7 +567,7 @@ def skill_file(self) -> str | None:
567567

568568
@property
569569
def skill_well_known(self) -> bool:
570-
"""Check if .well-known/skills/default/SKILL.md should be generated."""
570+
"""Check if .well-known/agent-skills/ discovery files should be generated."""
571571
return self.get("skill.well_known", True)
572572

573573
@property

great_docs/core.py

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11199,9 +11199,8 @@ def _inject_version_selector(self, quarto_yml: Path) -> None:
1119911199
"""
1120011200
Inject the version selector widget into the Quarto config.
1120111201

11202-
Adds the version-selector.js script and a `<meta name="gd-version-map">`
11203-
tag containing the serialized `_version_map.json` data so the widget
11204-
can resolve versions client-side.
11202+
Adds the version-selector.js script and a `<meta name="gd-version-map">` tag containing the
11203+
serialized `_version_map.json` data so the widget can resolve versions client-side.
1120511204
"""
1120611205
import html as html_mod
1120711206

@@ -11721,15 +11720,16 @@ def _generate_skill_md(self) -> None:
1172111720
"""
1172211721
Generate a SKILL.md file conforming to the Agent Skills specification.
1172311722

11724-
Creates a skill file that gives AI coding agents structured context about the
11725-
documented packageits capabilities, API decision table, gotchas, and links
11726-
to comprehensive documentation.
11723+
Creates a skill file that gives AI coding agents structured context about the documented
11724+
package: its capabilities, API decision table, gotchas, and links to comprehensive
11725+
documentation.
1172711726

11728-
If the user has provided a hand-written SKILL.md via `skill.file` in
11729-
`great-docs.yml`, that file is copied verbatim instead of generating one.
11727+
If the user has provided a hand-written SKILL.md via `skill.file` in `great-docs.yml`, that
11728+
file is copied verbatim instead of generating one.
1173011729

11731-
The generated file is written to `<docs>/skill.md` and optionally copied to
11732-
`<docs>/.well-known/skills/default/SKILL.md` for auto-discovery.
11730+
The generated file is written to `<docs>/skill.md` and optionally published at
11731+
`<docs>/.well-known/agent-skills/<name>/SKILL.md` with a discovery manifest at
11732+
`<docs>/.well-known/agent-skills/index.json` for `npx skills add` auto-discovery.
1173311733
"""
1173411734
import shutil
1173511735

@@ -11939,25 +11939,24 @@ def _generate_skill_md(self) -> None:
1193911939

1194011940
def _generate_skills_page(self, skill_path: "Path", *, skill_dir: "Path | None" = None) -> None:
1194111941
"""
11942-
Generate a `skills.qmd` page that renders the raw SKILL.md content in a
11943-
styled, human-readable format.
11942+
Generate a `skills.qmd` page that renders the raw SKILL.md content in a styled,
11943+
human-readable format.
1194411944

11945-
The page displays the skill's YAML frontmatter as a highlighted block and
11946-
the Markdown body with color-coded headings and monospaced fonta halfway
11947-
point between raw Markdown and fully rendered HTML.
11945+
The page displays the skill's YAML frontmatter as a highlighted block and the Markdown body
11946+
with color-coded headings and monospaced font: a halfway point between raw Markdown and
11947+
fully rendered HTML.
1194811948

11949-
When *skill_dir* points to a curated skill directory that contains companion
11950-
subdirectories (`references/`, `scripts/`, `assets/`), a directory
11951-
tree is rendered before the SKILL.md and each `.md` / `.sh` file is
11952-
displayed in its own text area with anchor links.
11949+
When *skill_dir* points to a curated skill directory that contains companion subdirectories
11950+
(`references/`, `scripts/`, `assets/`), a directory tree is rendered before the SKILL.md and
11951+
each `.md` / `.sh` file is displayed in its own text area with anchor links.
1195311952

1195411953
Parameters
1195511954
----------
1195611955
skill_path
1195711956
Path to the skill.md file to render.
1195811957
skill_dir
11959-
Optional path to the curated skill directory containing SKILL.md and
11960-
its companion subdirectories.
11958+
Optional path to the curated skill directory containing SKILL.md and its companion
11959+
subdirectories.
1196111960
"""
1196211961
import re
1196311962

@@ -12257,7 +12256,13 @@ def _t(key: str, fallback: str) -> str:
1225712256

1225812257
def _place_well_known_skill(self, skill_path: "Path") -> None:
1225912258
"""
12260-
Copy the SKILL.md to .well-known/skills/default/ for auto-discovery.
12259+
Copy the SKILL.md to .well-known/ directories for auto-discovery.
12260+
12261+
Places the skill at two well-known locations:
12262+
12263+
1. `.well-known/agent-skills/{name}/SKILL.md` with an `index.json` discovery manifest: the
12264+
preferred path used by `npx skills add`.
12265+
2. `.well-known/skills/default/SKILL.md`: legacy fallback.
1226112266

1226212267
Parameters
1226312268
----------
@@ -12269,11 +12274,37 @@ def _place_well_known_skill(self, skill_path: "Path") -> None:
1226912274
if not self._config.skill_well_known:
1227012275
return
1227112276

12277+
# Parse frontmatter to extract skill name and description
12278+
content = skill_path.read_text(encoding="utf-8")
12279+
fm, _ = self._split_frontmatter(content)
12280+
skill_name = fm.get("name", "default")
12281+
skill_description = fm.get("description", "")
12282+
if isinstance(skill_description, str):
12283+
skill_description = skill_description.strip()
12284+
12285+
# --- Preferred: .well-known/agent-skills/{name}/SKILL.md + index.json ---
12286+
agent_skills_dir = self.project_path / ".well-known" / "agent-skills" / skill_name
12287+
agent_skills_dir.mkdir(parents=True, exist_ok=True)
12288+
shutil.copy2(skill_path, agent_skills_dir / "SKILL.md")
12289+
12290+
index_data = {
12291+
"skills": [
12292+
{
12293+
"name": skill_name,
12294+
"description": skill_description,
12295+
"files": ["SKILL.md"],
12296+
}
12297+
]
12298+
}
12299+
index_path = self.project_path / ".well-known" / "agent-skills" / "index.json"
12300+
with open(index_path, "w", encoding="utf-8") as f:
12301+
json.dump(index_data, f, indent=2)
12302+
f.write("\n")
12303+
12304+
# --- Legacy: .well-known/skills/default/SKILL.md ---
1227212305
well_known_dir = self.project_path / ".well-known" / "skills" / "default"
1227312306
well_known_dir.mkdir(parents=True, exist_ok=True)
12274-
12275-
dest = well_known_dir / "SKILL.md"
12276-
shutil.copy2(skill_path, dest)
12307+
shutil.copy2(skill_path, well_known_dir / "SKILL.md")
1227712308

1227812309
# ══════════════════════════════════════════════════════════════════════════
1227912310
# SEO GENERATION METHODS
@@ -12337,8 +12368,8 @@ def _generate_sitemap_xml(self) -> None:
1233712368
"""
1233812369
Generate a sitemap.xml file for search engine indexing.
1233912370

12340-
Creates an XML sitemap at _site/sitemap.xml with proper priorities
12341-
and change frequencies based on page type.
12371+
Creates an XML sitemap at _site/sitemap.xml with proper priorities and change frequencies
12372+
based on page type.
1234212373
"""
1234312374
if not self._config.sitemap_enabled:
1234412375
return # pragma: no cover
@@ -12476,9 +12507,9 @@ def _resolve_social_card_image_url(self) -> str | None:
1247612507
"""
1247712508
Resolve the social card image to an absolute URL.
1247812509

12479-
If the configured image is a local file path, copies it to the build
12480-
directory and returns the site-relative path. If it's already a URL,
12481-
returns it as-is. If no image is configured, returns None.
12510+
If the configured image is a local file path, copies it to the build directory and returns
12511+
the site-relative path. If it's already a URL, returns it as-is. If no image is configured,
12512+
returns `None`.
1248212513

1248312514
Returns
1248412515
-------
@@ -12617,9 +12648,7 @@ def _annotate(pages: list[dict[str, object]]) -> list[dict[str, object]]:
1261712648
total_seconds += ver_total
1261812649
versions_payload[tag] = {
1261912650
"seconds": ver_total,
12620-
"pages": sorted(
12621-
_annotate(timings), key=lambda t: t["seconds"], reverse=True
12622-
),
12651+
"pages": sorted(_annotate(timings), key=lambda t: t["seconds"], reverse=True),
1262312652
}
1262412653
payload["total_seconds"] = round(total_seconds, 3)
1262512654
payload["versions"] = versions_payload
@@ -12696,8 +12725,8 @@ def _get_user_guide_text_for_llms(self) -> str:
1269612725
"""
1269712726
Get User Guide content formatted for llms-full.txt.
1269812727

12699-
Reads all user guide .qmd files in order and extracts their content,
12700-
stripping YAML frontmatter but preserving the document structure.
12728+
Reads all user guide .qmd files in order and extracts their content, stripping YAML
12729+
frontmatter but preserving the document structure.
1270112730

1270212731
Returns
1270312732
-------
@@ -13501,8 +13530,7 @@ class Result:
1350113530
mock_modified = _expand_mock_cells(self.project_path)
1350213531
if mock_modified:
1350313532
log.detail(
13504-
f"Expanded {len(mock_modified)} mock-code cell(s): "
13505-
+ ", ".join(mock_modified)
13533+
f"Expanded {len(mock_modified)} mock-code cell(s): " + ", ".join(mock_modified)
1350613534
)
1350713535

1350813536
# Get environment with QUARTO_PYTHON set

tests/test_great_docs.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38310,7 +38310,7 @@ def test_generate_skill_md_basic():
3831038310

3831138311

3831238312
def test_generate_skill_md_well_known():
38313-
"""Test that SKILL.md is copied to .well-known/ directory."""
38313+
"""Test that SKILL.md is copied to .well-known/ directories with index.json."""
3831438314
with tempfile.TemporaryDirectory() as tmp_dir:
3831538315
docs = GreatDocs(project_path=tmp_dir)
3831638316

@@ -38339,14 +38339,30 @@ def test_generate_skill_md_well_known():
3833938339

3834038340
docs._generate_skill_md()
3834138341

38342-
# Check .well-known placement
38342+
# Check legacy .well-known/skills/default placement
3834338343
well_known = great_docs_dir / ".well-known" / "skills" / "default" / "SKILL.md"
3834438344
assert well_known.exists()
3834538345

3834638346
# Content should match skill.md
3834738347
skill_md = great_docs_dir / "skill.md"
3834838348
assert skill_md.read_text() == well_known.read_text()
3834938349

38350+
# Check preferred .well-known/agent-skills/{name}/SKILL.md placement
38351+
agent_skill = great_docs_dir / ".well-known" / "agent-skills" / "test-package" / "SKILL.md"
38352+
assert agent_skill.exists()
38353+
assert skill_md.read_text() == agent_skill.read_text()
38354+
38355+
# Check index.json discovery manifest
38356+
index_json = great_docs_dir / ".well-known" / "agent-skills" / "index.json"
38357+
assert index_json.exists()
38358+
import json
38359+
38360+
index_data = json.loads(index_json.read_text())
38361+
assert "skills" in index_data
38362+
assert len(index_data["skills"]) == 1
38363+
assert index_data["skills"][0]["name"] == "test-package"
38364+
assert "SKILL.md" in index_data["skills"][0]["files"]
38365+
3835038366

3835138367
def test_generate_skill_md_disabled():
3835238368
"""Test that SKILL.md is not generated when skill.enabled is false."""

user_guide/05-configuration.qmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ reference:
599599

600600
Great Docs supports the [Agent Skills](https://agentskills.io/) open standard, which gives AI coding agents (Claude Code, GitHub Copilot, Cursor, Codex, Gemini CLI, and 30+ others) structured context about your package so they can write better code when using it.
601601

602-
During the build, a `skill.md` file is placed in your docs directory (and at `.well-known/skills/default/SKILL.md` for auto-discovery). Users of your package can then install the skill into their agent of choice:
602+
During the build, a `skill.md` file is placed in your docs directory and published at `.well-known/agent-skills/<name>/SKILL.md` with a discovery manifest at `.well-known/agent-skills/index.json` (plus a legacy copy at `.well-known/skills/default/SKILL.md`). Users of your package can then install the skill into their agent of choice:
603603

604604
```bash
605605
npx skills add https://your-docs-site.com

0 commit comments

Comments
 (0)