Skill: zack-dev-cm/artifact-deck (version 1.0.6)

--- name: artifact-deck description: Public OpenClaw skill for generating reproducible PPTX decks from project notes, status bullets, and screenshots. Use when the user wants a stakeholder-ready deck from repo notes, release summaries, audit outputs, or screenshot evidence, and the deliverable should be a rebuildable `.pptx` rather than a freeform narrative. version: 1.0.6 homepage: https://github.com/zack-dev-cm/artifact-deck license: MIT-0 user-invocable: true metadata: {"openclaw":{"homepage":"https://github.com/zack-dev-cm/artifact-deck","skillKey":"artifact-deck","requires":{"anyBins":["python3","python"]}}} --- # Artifact Deck ## Goal Turn curated project notes and screenshots into a clean, reproducible `.pptx` deck: - one manifest that defines the deck title, subtitle, status slides, and optional appendix images - one input check before build - one local PPTX build - one share-safe markdown summary with a rebuild command template and slide list This skill is for deterministic stakeholder packaging, not autonomous research or design generation. It requires Python 3.9+ and `python-pptx`. ## Use This Skill When - release notes, weekly updates, or launch artifacts need to become a decision-ready deck - browser screenshots or diagrams should be included as appendix slides - the user wants a rebuildable `.pptx` instead of a one-off manual slide edit - you need a concise executive or client status deck from repo-local notes ## Quick Start 1. Initialize the manifest. - Ensure the active Python environment already has `python-pptx` available. - Use `python3 {baseDir}/scripts/init_artifact_deck_manifest.py --title <title> --subtitle <subtitle> --section "What Changed=/abs/path/changes.md" --out <manifest.json>`. - Repeat `--section` for more markdown-backed status slides. - Optional: repeat `--slide "Risks=Pending scan|Need approval"` for direct bullet slides. - Optional: repeat `--image "Browser Proof=/abs/path/screenshot.png|Upload flow after publish"` for appendix slides. 2. Validate the inputs. - Use `python3 {baseDir}/scripts/check_artifact_deck_inputs.py --manifest <manifest.json> --out <check.json>`. - Fix missing content or image-path errors before building. 3. Build the deck. - Use `python3 {baseDir}/scripts/build_artifact_deck.py --manifest <manifest.json> --deck-out <deck.pptx> --out <build.json>`. - This writes the `.pptx` and a small JSON build summary. 4. Render the summary. - Use `python3 {baseDir}/scripts/render_artifact_deck_summary.py --manifest <manifest.json> --check <check.json> --build <build.json> --out <summary.md>`. - Share the deck and the summary together; the summary omits absolute local paths and keeps only a rebuild command template. ## Operating Rules ### Scope rules - Keep the promise narrow: curated notes and screenshots in, reproducible `.pptx` out. - Prefer concise bullets. Six bullets per slide is a practical ceiling. - Treat missing or mistyped file paths as hard failures. ### Deck rules - Use a title slide plus focused status slides. - Put screenshots and diagrams in appendix slides unless the user explicitly wants them inline. - Keep slide titles explicit: `What Changed`, `Risks`, `Asks`, `Appendix`, or equivalent. ## Bundled Scripts - `scripts/init_artifact_deck_manifest.py` - Build a manifest from markdown sections, direct bullet slides, and optional image appendix entries. - `scripts/check_artifact_deck_inputs.py` - Validate manifest structure, bullet availability, and image paths. - `scripts/build_artifact_deck.py` - Generate the `.pptx` and a machine-readable build summary. - `scripts/render_artifact_deck_summary.py` - Render a concise markdown summary with the deck filename, slide count, and a rebuild command template.

path: LICENSE
content: MIT No Attribution

Copyright (c) 2026 Zakhar Pashkin

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


path: agents/openai.yaml
content: interface:
  display_name: "Artifact Deck"
  short_description: "Generate reproducible PPTX decks from project notes, status bullets, and screenshots"
  default_prompt: "Use $artifact-deck to turn curated project notes, bullet lists, and screenshots into a simple reproducible PPTX deck with the default layout, validate the inputs, build the deck locally, and render a share-safe markdown summary."


path: scripts/build_artifact_deck.py
content: #!/usr/bin/env python3
"""Build a PPTX deck from an Artifact Deck manifest."""

from __future__ import annotations

import argparse
from pathlib import Path
from typing import Any, Dict, List

from pptx import Presentation
from pptx.enum.text import PP_ALIGN
from pptx.util import Inches, Pt

from deck_common import ensure_existing_path, load_json, slide_titles, write_json


def add_title_slide(prs: Presentation, title: str, subtitle: str) -> None:
    slide = prs.slides.add_slide(prs.slide_layouts[0])
    slide.shapes.title.text = title
    slide.placeholders[1].text = subtitle


def add_bullet_slide(prs: Presentation, title: str, bullets: List[str]) -> None:
    slide = prs.slides.add_slide(prs.slide_layouts[1])
    slide.shapes.title.text = title
    text_frame = slide.shapes.placeholders[1].text_frame
    text_frame.clear()
    for index, bullet in enumerate(bullets):
        paragraph = text_frame.paragraphs[0] if index == 0 else text_frame.add_paragraph()
        paragraph.text = bullet
        paragraph.level = 0
        paragraph.font.size = Pt(20)


def add_image_slide(prs: Presentation, title: str, image_path: Path, caption: str = "") -> None:
    slide = prs.slides.add_slide(prs.slide_layouts[5])
    slide.shapes.title.text = title
    slide.shapes.add_picture(str(image_path), Inches(0.6), Inches(1.4), width=Inches(12.1))
    if caption:
        box = slide.shapes.add_textbox(Inches(0.6), Inches(6.85), Inches(12.1), Inches(0.5))
        text_frame = box.text_frame
        text_frame.text = caption
        text_frame.paragraphs[0].alignment = PP_ALIGN.LEFT
        text_frame.paragraphs[0].font.size = Pt(14)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--manifest", required=True, help="Artifact Deck manifest JSON.")
    parser.add_argument("--deck-out", required=True, help="Output PPTX path.")
    parser.add_argument("--out", required=True, help="Output build JSON path.")
    args = parser.parse_args()

    manifest_path = ensure_existing_path(args.manifest, "--manifest")
    manifest = load_json(manifest_path)
    deck_out = Path(args.deck_out).expanduser().resolve()
    out_path = Path(args.out).expanduser().resolve()

    prs = Presentation()
    prs.slide_width = Inches(13.333)
    prs.slide_height = Inches(7.5)

    title = str(manifest.get("title") or "").strip()
    subtitle = str(manifest.get("subtitle") or "").strip()
    add_title_slide(prs, title, subtitle)

    slides = manifest.get("slides") or []
    for slide in slides:
        slide_title = str(slide.get("title") or "").strip()
        bullets = [str(item).strip() for item in (slide.get("bullets") or []) if str(item).strip()]
        if slide_title and bullets:
            add_bullet_slide(prs, slide_title, bullets)

    images = manifest.get("images") or []
    for image in images:
        image_title = str(image.get("title") or "").strip()
        image_path = ensure_existing_path(str(image.get("path") or ""), "--image path")
        caption = str(image.get("caption") or "").strip()
        if image_title:
            add_image_slide(prs, image_title, image_path, caption)

    deck_out.parent.mkdir(parents=True, exist_ok=True)
    prs.save(str(deck_out))

    payload: Dict[str, Any] = {
        "manifest": str(manifest_path),
        "deck_path": str(deck_out),
        "slide_count": len(prs.slides),
        "image_slide_count": len(images),
        "slide_titles": slide_titles(manifest),
    }
    write_json(out_path, payload)
    print(out_path)
    return 0


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


path: scripts/check_artifact_deck_inputs.py
content: #!/usr/bin/env python3
"""Validate an Artifact Deck manifest before build."""

from __future__ import annotations

import argparse
from pathlib import Path
from typing import Any, Dict, List

from deck_common import MAX_BULLETS, ensure_existing_path, load_json, write_json


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--manifest", required=True, help="Artifact Deck manifest JSON.")
    parser.add_argument("--out", required=True, help="Validation report JSON.")
    args = parser.parse_args()

    manifest_path = ensure_existing_path(args.manifest, "--manifest")
    manifest = load_json(manifest_path)
    out_path = Path(args.out).expanduser().resolve()

    errors: List[Dict[str, str]] = []
    warnings: List[Dict[str, str]] = []

    title = str(manifest.get("title") or "").strip()
    slides = manifest.get("slides") or []
    images = manifest.get("images") or []

    if not title:
        errors.append({"code": "missing-title", "message": "Manifest title is required."})
    if not slides and not images:
        errors.append({"code": "empty-deck", "message": "Manifest must contain at least one slide or image appendix entry."})

    for index, slide in enumerate(slides, start=1):
        slide_title = str(slide.get("title") or "").strip()
        bullets = slide.get("bullets") or []
        if not slide_title:
            errors.append({"code": "missing-slide-title", "message": f"Slide {index} is missing a title."})
        if not isinstance(bullets, list) or not bullets:
            errors.append({"code": "missing-slide-bullets", "message": f"Slide {slide_title or index} has no bullets."})
            continue
        cleaned = [str(item).strip() for item in bullets if str(item).strip()]
        if not cleaned:
            errors.append({"code": "empty-slide-bullets", "message": f"Slide {slide_title or index} has no usable bullets."})
        if len(cleaned) > MAX_BULLETS:
            warnings.append({"code": "too-many-bullets", "message": f"Slide {slide_title or index} has more than {MAX_BULLETS} bullets."})

    for index, image in enumerate(images, start=1):
        image_title = str(image.get("title") or "").strip()
        image_path = str(image.get("path") or "").strip()
        if not image_title:
            errors.append({"code": "missing-image-title", "message": f"Image entry {index} is missing a title."})
        if not image_path:
            errors.append({"code": "missing-image-path", "message": f"Image entry {image_title or index} is missing a path."})
            continue
        if not Path(image_path).expanduser().resolve().exists():
            errors.append({"code": "missing-image-file", "message": f"Image entry {image_title or index} points to a missing file: {image_path}"})

    payload: Dict[str, Any] = {
        "manifest": str(manifest_path),
        "slide_count": len(slides),
        "image_count": len(images),
        "error_count": len(errors),
        "warning_count": len(warnings),
        "status": "fix-required" if errors else "ok",
        "errors": errors,
        "warnings": warnings,
    }
    write_json(out_path, payload)
    print(out_path)
    return 0


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


path: scripts/deck_common.py
content: #!/usr/bin/env python3
"""Shared helpers for Artifact Deck."""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, Dict, List

MAX_BULLETS = 6


def ensure_existing_path(raw_path: str, label: str) -> Path:
    path = Path(raw_path).expanduser().resolve()
    if not path.exists():
        raise FileNotFoundError(f"{label} does not exist: {path}")
    return path


def write_json(path: Path, payload: Dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")


def load_json(path: Path) -> Dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def parse_section_spec(spec: str) -> tuple[str, str]:
    if "=" not in spec:
        raise ValueError(f"Expected TITLE=PATH, got: {spec}")
    title, raw_path = spec.split("=", 1)
    title = title.strip()
    raw_path = raw_path.strip()
    if not title or not raw_path:
        raise ValueError(f"Expected TITLE=PATH, got: {spec}")
    return title, raw_path


def parse_slide_spec(spec: str) -> Dict[str, Any]:
    if "=" not in spec:
        raise ValueError(f"Expected TITLE=bullet one|bullet two, got: {spec}")
    title, raw_bullets = spec.split("=", 1)
    title = title.strip()
    bullets = [item.strip() for item in raw_bullets.split("|") if item.strip()]
    if not title or not bullets:
        raise ValueError(f"Expected TITLE=bullet one|bullet two, got: {spec}")
    return {"title": title, "bullets": bullets[:MAX_BULLETS], "source": "inline"}


def parse_image_spec(spec: str) -> Dict[str, str]:
    if "=" not in spec:
        raise ValueError(f"Expected TITLE=PATH|CAPTION, got: {spec}")
    title, raw = spec.split("=", 1)
    title = title.strip()
    path_part, _, caption = raw.partition("|")
    path_part = path_part.strip()
    caption = caption.strip()
    if not title or not path_part:
        raise ValueError(f"Expected TITLE=PATH|CAPTION, got: {spec}")
    return {"title": title, "path": path_part, "caption": caption}


def normalize_bullet(line: str) -> str:
    text = line.strip()
    text = re.sub(r"^[-*+]\s+", "", text)
    text = re.sub(r"^\d+[.)]\s+", "", text)
    return text.strip()


def bullets_from_markdown(path: Path, limit: int = MAX_BULLETS) -> List[str]:
    bullets: List[str] = []
    lines = path.read_text(encoding="utf-8").splitlines()
    for raw in lines:
        stripped = raw.strip()
        if not stripped or stripped.startswith("#"):
            continue
        cleaned = normalize_bullet(stripped)
        if not cleaned:
            continue
        bullets.append(cleaned)
        if len(bullets) >= limit:
            break
    return bullets


def slide_titles(manifest: Dict[str, Any]) -> List[str]:
    titles = [str(manifest.get("title") or "").strip()]
    for slide in manifest.get("slides") or []:
        title = str(slide.get("title") or "").strip()
        if title:
            titles.append(title)
    for image in manifest.get("images") or []:
        title = str(image.get("title") or "").strip()
        if title:
            titles.append(title)
    return [title for title in titles if title]


path: scripts/init_artifact_deck_manifest.py
content: #!/usr/bin/env python3
"""Create an Artifact Deck manifest from markdown sections and optional inline slides."""

from __future__ import annotations

import argparse
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List

from deck_common import bullets_from_markdown, ensure_existing_path, parse_image_spec, parse_section_spec, parse_slide_spec, write_json


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--title", required=True, help="Deck title.")
    parser.add_argument("--subtitle", default="", help="Deck subtitle.")
    parser.add_argument("--section", action="append", default=[], help="Repeatable TITLE=/abs/path/to/notes.md input.")
    parser.add_argument("--slide", action="append", default=[], help="Repeatable TITLE=bullet one|bullet two input.")
    parser.add_argument("--image", action="append", default=[], help="Repeatable TITLE=/abs/path/image.png|Caption input.")
    parser.add_argument("--out", required=True, help="Output manifest path.")
    args = parser.parse_args()

    slides: List[Dict[str, Any]] = []
    for spec in args.section:
        title, raw_path = parse_section_spec(spec)
        source_path = ensure_existing_path(raw_path, "--section path")
        bullets = bullets_from_markdown(source_path)
        slides.append(
            {
                "title": title,
                "bullets": bullets,
                "source": str(source_path),
            }
        )

    for spec in args.slide:
        slides.append(parse_slide_spec(spec))

    images: List[Dict[str, str]] = []
    for spec in args.image:
        item = parse_image_spec(spec)
        source_path = ensure_existing_path(item["path"], "--image path")
        images.append(
            {
                "title": item["title"],
                "path": str(source_path),
                "caption": item["caption"],
            }
        )

    payload = {
        "title": args.title.strip(),
        "subtitle": args.subtitle.strip(),
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "slides": slides,
        "images": images,
    }
    out_path = Path(args.out).expanduser().resolve()
    write_json(out_path, payload)
    print(out_path)
    return 0


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


path: scripts/render_artifact_deck_summary.py
content: #!/usr/bin/env python3
"""Render a markdown summary for an Artifact Deck build."""

from __future__ import annotations

import argparse
from pathlib import Path

from deck_common import ensure_existing_path, load_json


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--manifest", required=True, help="Artifact Deck manifest JSON.")
    parser.add_argument("--check", required=True, help="Validation JSON.")
    parser.add_argument("--build", required=True, help="Build JSON.")
    parser.add_argument("--out", required=True, help="Output markdown path.")
    args = parser.parse_args()

    manifest_path = ensure_existing_path(args.manifest, "--manifest")
    check_path = ensure_existing_path(args.check, "--check")
    build_path = ensure_existing_path(args.build, "--build")
    out_path = Path(args.out).expanduser().resolve()

    manifest = load_json(manifest_path)
    check = load_json(check_path)
    build = load_json(build_path)

    deck_path = str(build.get("deck_path") or "")
    deck_label = Path(deck_path).name if deck_path else "deck.pptx"
    lines = [
        "# Artifact Deck Summary",
        "",
        f"- Validation: **{check.get('status', 'unknown')}**",
        f"- Deck file: **{deck_label}**",
        f"- Slide count: **{build.get('slide_count', 0)}**",
        f"- Image appendix slides: **{build.get('image_slide_count', 0)}**",
        "",
        "## Rebuild",
        "",
        "Run from the `artifact-deck` repo root:",
        "",
        "```bash",
        "python3 skill/artifact-deck/scripts/build_artifact_deck.py \\",
        "  --manifest <manifest.json> \\",
        f"  --deck-out {deck_label} \\",
        "  --out <build.json>",
        "```",
        "",
        "## Slides",
        "",
    ]
    for title in build.get("slide_titles") or [manifest.get("title")]:
        lines.append(f"- {title}")

    if check.get("warnings"):
        lines.extend(["", "## Validation Warnings", ""])
        for warning in check.get("warnings") or []:
            lines.append(f"- {warning.get('message')}")

    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(out_path)
    return 0


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


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)