From bb350e1f507453a9448ca62752d8784e643e8911 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Fri, 8 May 2026 03:14:08 -0400 Subject: [PATCH] feat(skill-export): generate Claude Code SKILL.md bundles from a help corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module ``attune_author.skill_export`` walks an attune-help-compatible templates/ tree and writes one SKILL.md per concept template: output_dir/ └── / └── SKILL.md frontmatter: name, description; body: concept Notable details: * Canonical slug. Concept files often carry an organizational prefix (``tool-security-audit.md``, ``task-debugging-sessions.md``) that doesn't appear in summaries.json. The exporter strips the prefix so the SKILL.md ``name`` matches what downstream skill consumers expect. * Description resolution chain. summaries.json (preferred) → frontmatter ``description`` → first-paragraph extraction → generic stub. Skill triggering quality depends on the description, so the chain prefers explicit human-curated text over extractive fallbacks. * Defensive defaults. Unsafe slugs, malformed frontmatter, empty bodies, and pre-existing SKILL.md files are skipped with a recorded reason rather than raising — the CLI prints a one-line summary per skip. New ``attune-author export-skills`` CLI subcommand defaults to attune-help's bundled corpus (resolved via the rag adapter introduced in attune-help 0.11.0) so the simplest invocation is just ``attune-author export-skills --output ./skills``. Bumps to 0.10.0. Phase one of the attune-help-as-skill opportunity from the 2026-05-08 enhancements briefing — the attune-help corpus becomes a generator for skills rather than a runtime competitor. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 35 +++++ pyproject.toml | 2 +- src/attune_author/__init__.py | 2 +- src/attune_author/cli.py | 81 ++++++++++ src/attune_author/skill_export.py | 243 ++++++++++++++++++++++++++++++ tests/test_skill_export.py | 203 +++++++++++++++++++++++++ 6 files changed, 564 insertions(+), 2 deletions(-) create mode 100644 src/attune_author/skill_export.py create mode 100644 tests/test_skill_export.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 33fd775..bcc36d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,41 @@ and this project adheres to Work in progress for the next release. Add entries here as changes land, not at tag time. +## [0.10.0] - 2026-05-08 + +### Added + +- **`attune_author.skill_export`** — exporter that writes one + Claude Code `SKILL.md` per concept template in an + attune-help-compatible corpus. Frontmatter `name` is the + canonical feature slug (with `tool-` / `task-` prefixes + stripped for parity with `summaries.json`); `description` + resolves through summaries.json → frontmatter → first-paragraph + extraction → generic stub. Body is the concept template + content. Per-feature output goes to + `//SKILL.md`. +- **`attune-author export-skills`** CLI subcommand. Defaults + to attune-help's bundled corpus (resolved via + `attune_help.adapters.rag.AttuneHelpAdapter`); `--source` + overrides for project-local templates. `--overwrite` replaces + existing SKILL.md files; absent it skips them with a recorded + reason. +- 18 new tests in `tests/test_skill_export.py` covering canonical + slug stripping, first-paragraph extraction, summary lookup, + fallback chain, edge cases (missing summaries.json, empty + body, unsafe slug, overwrite semantics). + +### Notes + +- Exporting the bundled attune-help corpus today produces 43 + skills covering the documented attune-ai workflows. Skill + consumers (Claude Code, the marketplace) can load these as + native skills with description-driven triggering — the + attune-help corpus becomes a *generator* for skills rather + than a runtime competitor. Phase one of the + attune-help-as-skill recommendation from the 2026-05-08 + enhancements briefing. + ## [0.9.1] - 2026-05-08 ### Added diff --git a/pyproject.toml b/pyproject.toml index cb73d0a..e4e17ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "attune-author" -version = "0.9.1" +version = "0.10.0" description = "Documentation authoring and maintenance for the attune ecosystem — generate, maintain, and validate help content with AI assistance." readme = {file = "README.md", content-type = "text/markdown"} requires-python = ">=3.10" diff --git a/src/attune_author/__init__.py b/src/attune_author/__init__.py index 50f03ee..c3fc3ad 100644 --- a/src/attune_author/__init__.py +++ b/src/attune_author/__init__.py @@ -5,7 +5,7 @@ attune-help (reader) and attune-ai (full dev workflows). """ -__version__ = "0.9.1" +__version__ = "0.10.0" from attune_author.manifest import Feature, Manifest, load_manifest from attune_author.staleness import StalenessReport, check_staleness, compute_source_hash diff --git a/src/attune_author/cli.py b/src/attune_author/cli.py index 8c4bb70..d945c52 100644 --- a/src/attune_author/cli.py +++ b/src/attune_author/cli.py @@ -247,6 +247,36 @@ def _build_parser() -> argparse.ArgumentParser: help="Target audience (default: %(default)s).", ) + p_skills = sub.add_parser( + "export-skills", + help="Export an attune-help corpus as Claude Code skill bundles", + description=( + "Walk an attune-help-compatible templates/ directory and write " + "one Claude Code SKILL.md per concept template. Each output " + "directory becomes a loadable skill: SKILL.md frontmatter " + "carries the name and description (sourced from " + "summaries.json) and the body is the concept template." + ), + ) + p_skills.add_argument( + "--source", + default=None, + help=( + "Path to a templates/ directory (must contain concepts/ and " + "summaries.json). Defaults to attune-help's bundled corpus." + ), + ) + p_skills.add_argument( + "--output", + default="./skills", + help="Directory to write SKILL.md files into (default: %(default)s).", + ) + p_skills.add_argument( + "--overwrite", + action="store_true", + help="Replace any existing SKILL.md instead of skipping it.", + ) + return parser @@ -277,6 +307,7 @@ def _dispatch(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: "docs": _cmd_docs, "cache": _cmd_cache, "edit": _cmd_edit, + "export-skills": _cmd_export_skills, } handler = handlers.get(args.command) if handler is None: @@ -771,5 +802,55 @@ def _load_feature_names_for_welcome() -> list[str] | None: return names or None +def _cmd_export_skills(args: argparse.Namespace) -> int: + """Handle the ``export-skills`` command. + + Resolves ``--source`` (defaulting to attune-help's bundled + templates dir via the rag adapter), runs :func:`export_skills`, + and prints a written/skipped summary. + + Returns: + ``0`` on success — even when some features were skipped. + ``1`` if the source directory can't be resolved or doesn't + contain a ``concepts/`` subtree. + """ + from attune_author.skill_export import export_skills + + source: Path + if args.source: + source = Path(args.source).expanduser().resolve() + else: + try: + from attune_help.adapters.rag import AttuneHelpAdapter + except ImportError: + print( + "Could not import attune_help; pass --source " + "or install attune-help.", + file=sys.stderr, + ) + return 1 + source = AttuneHelpAdapter().templates_root + + if not (source / "concepts").is_dir(): + print( + f"Source has no concepts/ subdirectory: {source}\n" + f" Hint: pass a templates/ root, not the project or .help/ dir.", + file=sys.stderr, + ) + return 1 + + output = Path(args.output).expanduser().resolve() + result = export_skills(source, output, overwrite=args.overwrite) + + print(f"Exported {len(result.written)} skill(s) to {output}") + for record in result.written: + print(f" + {record.feature:<32} {record.body_chars} chars") + if result.skipped: + print(f"\nSkipped {len(result.skipped)}:") + for feature, reason in result.skipped: + print(f" - {feature:<32} {reason}") + return 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/attune_author/skill_export.py b/src/attune_author/skill_export.py new file mode 100644 index 0000000..f767a57 --- /dev/null +++ b/src/attune_author/skill_export.py @@ -0,0 +1,243 @@ +"""Generate Claude Code skill bundles from an attune-help template corpus. + +Each ``concepts/.md`` template plus its entry in +``summaries.json`` becomes one Claude Code skill:: + + output_dir/ + └── / + └── SKILL.md frontmatter: name, description; body: concept + +Skill consumers (Claude Code, the marketplace, anything that reads +SKILL.md) can then load the help corpus as native skills with +their own progressive-disclosure mechanism: SKILL.md is the +description-triggered entry point, and a future PR can layer task / +reference depth files alongside it for `Skill` to load on demand. + +This is a build-time export — there's no runtime coupling between +attune-author and Claude Code. The output directory is yours to +ship inside a plugin, commit to a repo, or `attune-help`'s own +distribution. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import frontmatter +import yaml + +logger = logging.getLogger(__name__) + +#: Slug pattern matching attune-help's ``is_safe_feature_name`` — keeps +#: the export defensive about untrusted input even though templates +#: typically come from a trusted manifest. +_SAFE_SLUG = re.compile(r"^[a-z][a-z0-9-]*$") + +#: Concept filenames in attune-help often carry a category prefix +#: (``tool-security-audit.md``, ``task-database-migrations.md``) for +#: in-corpus organisation. The canonical feature slug — the one +#: ``summaries.json`` is keyed by, and the one downstream skill +#: consumers expect — is the bare suffix. +_CATEGORY_PREFIXES: tuple[str, ...] = ("tool-", "task-") + + +def _canonical_slug(file_stem: str) -> str: + for prefix in _CATEGORY_PREFIXES: + if file_stem.startswith(prefix): + return file_stem[len(prefix) :] + return file_stem + + +def _first_paragraph_summary(body: str, max_chars: int = 200) -> str | None: + """Best-effort one-line description pulled from the body. + + Skips headings and blank lines, joins the first prose paragraph, + truncates at the first sentence boundary, and caps at ``max_chars``. + Returns ``None`` if no prose was found. + """ + lines: list[str] = [] + for raw in body.split("\n"): + stripped = raw.strip() + if not stripped: + if lines: + break + continue + if stripped.startswith("#"): + continue + lines.append(stripped) + if not lines: + return None + paragraph = " ".join(lines) + for delim in (". ", "? ", "! "): + head, sep, _ = paragraph.partition(delim) + if sep: + sentence = head + delim.strip() + if len(sentence) <= max_chars: + return sentence + break + if len(paragraph) > max_chars: + return paragraph[: max_chars - 1].rstrip() + "…" + return paragraph + + +@dataclass(frozen=True) +class SkillRecord: + """One generated SKILL.md plus its source provenance.""" + + feature: str + skill_path: Path + description: str + body_chars: int + + +@dataclass +class ExportResult: + """Outcome of a single :func:`export_skills` call. + + ``skipped`` records features the export couldn't write — missing + concept template, unsafe slug, malformed frontmatter — paired with + a one-line reason so a CLI caller can surface a useful summary. + """ + + written: list[SkillRecord] = field(default_factory=list) + skipped: list[tuple[str, str]] = field(default_factory=list) + + +def _load_summaries(template_dir: Path) -> dict[str, str]: + """Return ``{feature_slug: one-line description}`` from summaries.json. + + Missing or malformed file returns an empty dict — the export then + falls back to a generic description per feature. + """ + path = template_dir / "summaries.json" + if not path.exists(): + return {} + try: + import json + + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 - corrupted summaries fall back to {} + logger.warning("could not parse %s; ignoring", path) + return {} + if not isinstance(data, dict): + return {} + # Normalise to str keys + str values; drop anything else. + return {str(k): str(v) for k, v in data.items() if isinstance(v, str)} + + +def _read_concept(path: Path) -> tuple[dict[str, Any], str] | None: + """Return ``(frontmatter_dict, body_str)`` or ``None`` on parse failure.""" + try: + post = frontmatter.load(str(path)) + except Exception: # noqa: BLE001 - bad frontmatter / encoding falls back to skip + logger.warning("frontmatter parse failed for %s", path) + return None + return dict(post.metadata), post.content + + +def _build_skill_md(*, feature: str, description: str, body: str) -> str: + """Compose ``SKILL.md`` text given the inputs the export resolved. + + Uses YAML safe-dump for the frontmatter so titles with quotes / + colons can't break the SKILL.md format. + """ + fm = yaml.safe_dump( + {"name": feature, "description": description}, + sort_keys=False, + allow_unicode=True, + ).strip() + body = body.strip() + return f"---\n{fm}\n---\n\n{body}\n" + + +def export_skills( + template_dir: Path, + output_dir: Path, + *, + overwrite: bool = False, +) -> ExportResult: + """Write one ``/SKILL.md`` per concept template under ``output_dir``. + + Args: + template_dir: Root of an attune-help-compatible corpus + (typically ``attune_help.adapters.rag.AttuneHelpAdapter().templates_root``, + or a project's local ``.help/templates/``). + output_dir: Where to write the skill bundles. Created if missing. + overwrite: When ``True``, replace existing ``SKILL.md`` files; when + ``False`` (default), skip any feature whose target file already + exists and record it under :attr:`ExportResult.skipped`. + + Returns: + :class:`ExportResult` summarising what was written and what was + skipped (and why). + """ + + concepts_dir = template_dir / "concepts" + if not concepts_dir.is_dir(): + logger.warning("no concepts/ under %s; nothing to export", template_dir) + return ExportResult() + + summaries = _load_summaries(template_dir) + output_dir.mkdir(parents=True, exist_ok=True) + result = ExportResult() + + for concept_path in sorted(concepts_dir.glob("*.md")): + file_stem = concept_path.stem + if not _SAFE_SLUG.match(file_stem): + result.skipped.append((file_stem, "unsafe slug")) + continue + + feature = _canonical_slug(file_stem) + parsed = _read_concept(concept_path) + if parsed is None: + result.skipped.append((feature, "frontmatter parse failed")) + continue + meta, body = parsed + if not body.strip(): + result.skipped.append((feature, "empty concept body")) + continue + + description = ( + summaries.get(feature) + or summaries.get(file_stem) + or _description_from_meta(meta, feature) + or _first_paragraph_summary(body) + or f"Help content for the {feature.replace('-', ' ')} topic." + ) + skill_dir = output_dir / feature + skill_path = skill_dir / "SKILL.md" + + if skill_path.exists() and not overwrite: + result.skipped.append((feature, "exists (pass --overwrite to replace)")) + continue + + skill_dir.mkdir(parents=True, exist_ok=True) + skill_path.write_text( + _build_skill_md(feature=feature, description=description, body=body), + encoding="utf-8", + ) + result.written.append( + SkillRecord( + feature=feature, + skill_path=skill_path, + description=description, + body_chars=len(body.strip()), + ) + ) + + return result + + +def _description_from_meta(meta: dict[str, Any], feature: str) -> str | None: + """Return the template's explicit ``description`` frontmatter, if any. + + Returns ``None`` when missing so the resolution chain in + :func:`export_skills` falls through to body extraction and finally + to a generic stub. + """ + explicit = str(meta.get("description") or "").strip() + return explicit or None diff --git a/tests/test_skill_export.py b/tests/test_skill_export.py new file mode 100644 index 0000000..5ea4510 --- /dev/null +++ b/tests/test_skill_export.py @@ -0,0 +1,203 @@ +"""Tests for ``attune_author.skill_export``. + +Each test stages a small fixture corpus under ``tmp_path`` to keep the +test independent of the bundled attune-help corpus shape. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from textwrap import dedent + +import pytest + +from attune_author.skill_export import ( + ExportResult, + _canonical_slug, + _first_paragraph_summary, + export_skills, +) + + +def _write_template(root: Path, kind: str, slug: str, body: str = "") -> Path: + target = root / kind / f"{slug}.md" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(body or _default_body(slug), encoding="utf-8") + return target + + +def _default_body(slug: str) -> str: + return dedent(f"""\ + --- + type: concept + name: {slug} + tags: [test] + --- + + # {slug.replace('-', ' ').title()} + + {slug.replace('-', ' ').capitalize()} is a feature that does an + interesting thing for the test corpus. + + ## What + + It works. + """) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class TestCanonicalSlug: + def test_strips_tool_prefix(self) -> None: + assert _canonical_slug("tool-security-audit") == "security-audit" + + def test_strips_task_prefix(self) -> None: + assert _canonical_slug("task-error-handling-design") == "error-handling-design" + + def test_leaves_un_prefixed_slug(self) -> None: + assert _canonical_slug("audience-adaptation") == "audience-adaptation" + + +class TestFirstParagraphSummary: + def test_returns_first_sentence(self) -> None: + body = "# Heading\n\nThis is the first sentence. And this is the second." + assert _first_paragraph_summary(body) == "This is the first sentence." + + def test_skips_headings(self) -> None: + body = "# H1\n## H2\n\nReal text starts here. Continues." + assert _first_paragraph_summary(body) == "Real text starts here." + + def test_returns_none_when_only_headings(self) -> None: + body = "# Title\n\n## Subhead\n" + assert _first_paragraph_summary(body) is None + + def test_truncates_overlong_paragraph(self) -> None: + body = "# H\n\n" + ("x" * 250) + out = _first_paragraph_summary(body, max_chars=200) + assert out is not None + assert len(out) == 200 + assert out.endswith("…") + + +# --------------------------------------------------------------------------- +# export_skills happy path +# --------------------------------------------------------------------------- + + +@pytest.fixture +def corpus(tmp_path: Path) -> Path: + (tmp_path / "summaries.json").write_text( + json.dumps( + { + "security-audit": "Scans for vulnerabilities.", + "audience-adaptation": "Renders help differently per channel.", + } + ), + encoding="utf-8", + ) + _write_template(tmp_path, "concepts", "tool-security-audit") + _write_template(tmp_path, "concepts", "audience-adaptation") + _write_template(tmp_path, "concepts", "task-debugging-sessions") + return tmp_path + + +def test_writes_one_skill_per_concept(corpus: Path, tmp_path: Path) -> None: + out = tmp_path / "skills" + result = export_skills(corpus, out) + + written_features = sorted(r.feature for r in result.written) + assert written_features == ["audience-adaptation", "debugging-sessions", "security-audit"] + assert result.skipped == [] + + +def test_skill_md_contains_summary_description(corpus: Path, tmp_path: Path) -> None: + out = tmp_path / "skills" + export_skills(corpus, out) + skill = (out / "security-audit" / "SKILL.md").read_text(encoding="utf-8") + assert "name: security-audit" in skill + assert "Scans for vulnerabilities." in skill + + +def test_skill_md_falls_back_to_first_paragraph_when_no_summary( + corpus: Path, tmp_path: Path +) -> None: + out = tmp_path / "skills" + export_skills(corpus, out) + skill = (out / "debugging-sessions" / "SKILL.md").read_text(encoding="utf-8") + # No summary entry exists for debugging-sessions; expect extracted prose. + assert "is a feature that does an interesting thing" in skill + + +def test_canonical_slug_used_for_output_dir(corpus: Path, tmp_path: Path) -> None: + out = tmp_path / "skills" + export_skills(corpus, out) + # tool-security-audit.md → security-audit/SKILL.md + assert (out / "security-audit" / "SKILL.md").exists() + assert not (out / "tool-security-audit").exists() + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +def test_missing_concepts_dir_returns_empty(tmp_path: Path) -> None: + result = export_skills(tmp_path, tmp_path / "skills") + assert result.written == [] + assert result.skipped == [] + + +def test_empty_concept_body_is_skipped(tmp_path: Path) -> None: + (tmp_path / "concepts").mkdir() + (tmp_path / "concepts" / "empty.md").write_text("---\ntype: concept\n---\n\n", encoding="utf-8") + result = export_skills(tmp_path, tmp_path / "skills") + assert result.written == [] + assert result.skipped == [("empty", "empty concept body")] + + +def test_unsafe_slug_is_skipped(tmp_path: Path) -> None: + concepts = tmp_path / "concepts" + concepts.mkdir() + (concepts / "Bad Slug.md").write_text("---\n---\nhi", encoding="utf-8") + result = export_skills(tmp_path, tmp_path / "skills") + assert any(reason == "unsafe slug" for _, reason in result.skipped) + + +def test_existing_skill_is_skipped_without_overwrite(corpus: Path, tmp_path: Path) -> None: + out = tmp_path / "skills" + export_skills(corpus, out) # first pass writes + result = export_skills(corpus, out) # second pass skips + assert result.written == [] + assert all("exists" in reason for _, reason in result.skipped) + + +def test_overwrite_replaces_existing(corpus: Path, tmp_path: Path) -> None: + out = tmp_path / "skills" + export_skills(corpus, out) + skill_path = out / "security-audit" / "SKILL.md" + skill_path.write_text("# stale", encoding="utf-8") + result = export_skills(corpus, out, overwrite=True) + assert any(r.feature == "security-audit" for r in result.written) + assert "name: security-audit" in skill_path.read_text(encoding="utf-8") + + +def test_summaries_json_missing_uses_extracted_descriptions(tmp_path: Path) -> None: + """No summaries.json at all — every description comes from body extraction.""" + _write_template(tmp_path, "concepts", "alpha") + out = tmp_path / "skills" + result = export_skills(tmp_path, out) + assert len(result.written) == 1 + skill = (out / "alpha" / "SKILL.md").read_text(encoding="utf-8") + assert "alpha is a feature" in skill.lower() + + +def test_export_result_dataclass_initialises_empty() -> None: + """The dataclass with default factories shouldn't share state across instances.""" + a = ExportResult() + b = ExportResult() + a.written.append(None) # type: ignore[arg-type] + assert b.written == []