diff --git a/skills/publish-to-pages/SKILL.md b/skills/publish-to-pages/SKILL.md
new file mode 100644
index 000000000..a13a12864
--- /dev/null
+++ b/skills/publish-to-pages/SKILL.md
@@ -0,0 +1,90 @@
+---
+name: publish-to-pages
+description: 'Publish presentations and web content to GitHub Pages. Converts PPTX, PDF, HTML, or Google Slides to a live GitHub Pages URL. Handles repo creation, file conversion, Pages enablement, and returns the live URL. Use when the user wants to publish, deploy, or share a presentation or HTML file via GitHub Pages.'
+---
+
+# publish-to-pages
+
+Publish any presentation or web content to GitHub Pages in one shot.
+
+## 1. Prerequisites Check
+
+Run these silently. Only surface errors:
+
+```bash
+command -v gh >/dev/null || echo "MISSING: gh CLI — install from https://cli.github.com"
+gh auth status &>/dev/null || echo "MISSING: gh not authenticated — run 'gh auth login'"
+command -v python3 >/dev/null || echo "MISSING: python3 (needed for PPTX conversion)"
+```
+
+`poppler-utils` is optional (PDF conversion via `pdftoppm`). Don't block on it.
+
+## 2. Input Detection
+
+Determine input type from what the user provides:
+
+| Input | Detection |
+|-------|-----------|
+| HTML file | Extension `.html` or `.htm` |
+| PPTX file | Extension `.pptx` |
+| PDF file | Extension `.pdf` |
+| Google Slides URL | URL contains `docs.google.com/presentation` |
+
+Ask the user for a **repo name** if not provided. Default: filename without extension.
+
+## 3. Conversion
+
+### HTML
+No conversion needed. Use the file directly as `index.html`.
+
+### PPTX
+Run the conversion script:
+```bash
+python3 SKILL_DIR/scripts/convert-pptx.py INPUT_FILE /tmp/output.html
+```
+If `python-pptx` is missing, tell the user: `pip install python-pptx`
+
+### PDF
+Convert with the included script (requires `poppler-utils` for `pdftoppm`):
+```bash
+python3 SKILL_DIR/scripts/convert-pdf.py INPUT_FILE /tmp/output.html
+```
+Each page is rendered as a PNG and base64-embedded into a self-contained HTML with slide navigation.
+If `pdftoppm` is missing, tell the user: `apt install poppler-utils` (or `brew install poppler` on macOS).
+
+### Google Slides
+1. Extract the presentation ID from the URL (the long string between `/d/` and `/`)
+2. Download as PPTX:
+```bash
+curl -L "https://docs.google.com/presentation/d/PRESENTATION_ID/export/pptx" -o /tmp/slides.pptx
+```
+3. Then convert the PPTX using the convert script above.
+
+## 4. Publishing
+
+### Visibility
+Repos are created **public** by default. If the user specifies `private` (or wants a private repo), use `--private` — but note that GitHub Pages on private repos requires a Pro, Team, or Enterprise plan.
+
+### Publish
+```bash
+bash SKILL_DIR/scripts/publish.sh /path/to/index.html REPO_NAME public "Description"
+```
+
+Pass `private` instead of `public` if the user requests it.
+
+The script creates the repo, pushes `index.html`, and enables GitHub Pages.
+
+## 5. Output
+
+Tell the user:
+- **Repository:** `https://github.com/USERNAME/REPO_NAME`
+- **Live URL:** `https://USERNAME.github.io/REPO_NAME/`
+- **Note:** Pages takes 1-2 minutes to go live.
+
+## Error Handling
+
+- **Repo already exists:** Suggest appending a number (`my-slides-2`) or a date (`my-slides-2026`).
+- **Pages enablement fails:** Still return the repo URL. User can enable Pages manually in repo Settings.
+- **PPTX conversion fails:** Tell user to run `pip install python-pptx`.
+- **PDF conversion fails:** Suggest installing `poppler-utils` (`apt install poppler-utils` or `brew install poppler`).
+- **Google Slides download fails:** The presentation may not be publicly accessible. Ask user to make it viewable or download the PPTX manually.
diff --git a/skills/publish-to-pages/scripts/convert-pdf.py b/skills/publish-to-pages/scripts/convert-pdf.py
new file mode 100755
index 000000000..01c99e739
--- /dev/null
+++ b/skills/publish-to-pages/scripts/convert-pdf.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""Convert a PDF to a self-contained HTML presentation.
+
+Each page is rendered as a PNG image (via pdftoppm) and base64-embedded
+into a single HTML file with slide navigation (arrows, swipe, click).
+
+Requirements: poppler-utils (pdftoppm)
+Usage: python3 convert-pdf.py input.pdf [output.html]
+"""
+
+import base64
+import glob
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+
+def convert(pdf_path: str, output_path: str | None = None, dpi: int = 150):
+ pdf_path = str(Path(pdf_path).resolve())
+ if not Path(pdf_path).exists():
+ print(f"Error: {pdf_path} not found")
+ sys.exit(1)
+
+ # Check for pdftoppm
+ if subprocess.run(["which", "pdftoppm"], capture_output=True).returncode != 0:
+ print("Error: pdftoppm not found. Install poppler-utils:")
+ print(" apt install poppler-utils # Debian/Ubuntu")
+ print(" brew install poppler # macOS")
+ sys.exit(1)
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ prefix = os.path.join(tmpdir, "page")
+ result = subprocess.run(
+ ["pdftoppm", "-png", "-r", str(dpi), pdf_path, prefix],
+ capture_output=True, text=True
+ )
+ if result.returncode != 0:
+ print(f"Error converting PDF: {result.stderr}")
+ sys.exit(1)
+
+ pages = sorted(glob.glob(f"{prefix}-*.png"))
+ if not pages:
+ print("Error: No pages rendered from PDF")
+ sys.exit(1)
+
+ slides_html = []
+ for i, page_path in enumerate(pages, 1):
+ with open(page_path, "rb") as f:
+ b64 = base64.b64encode(f.read()).decode()
+ slides_html.append(
+ f''
+ f''
+ f'

'
+ f'
'
+ )
+
+ # Try to extract title from filename
+ title = Path(pdf_path).stem.replace("-", " ").replace("_", " ")
+
+ html = f'''
+
+
+
+
+{title}
+
+
+
+{chr(10).join(slides_html)}
+
+
+
+'''
+
+ output = output_path or str(Path(pdf_path).with_suffix('.html'))
+ Path(output).write_text(html, encoding='utf-8')
+ print(f"Converted to: {output}")
+ print(f"Pages: {len(slides_html)}")
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: convert-pdf.py [output.html]")
+ sys.exit(1)
+ convert(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)
diff --git a/skills/publish-to-pages/scripts/convert-pptx.py b/skills/publish-to-pages/scripts/convert-pptx.py
new file mode 100755
index 000000000..6423b14af
--- /dev/null
+++ b/skills/publish-to-pages/scripts/convert-pptx.py
@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+"""Convert a PPTX file to a self-contained HTML presentation with formatting preserved."""
+import sys
+import base64
+import io
+import re
+from pathlib import Path
+
+try:
+ from pptx import Presentation
+ from pptx.util import Inches, Pt, Emu
+ from pptx.enum.text import PP_ALIGN
+ from pptx.dml.color import RGBColor
+except ImportError:
+ print("ERROR: python-pptx not installed. Install with: pip install python-pptx")
+ sys.exit(1)
+
+
+def rgb_to_hex(rgb_color):
+ """Convert RGBColor to hex string."""
+ if rgb_color is None:
+ return None
+ try:
+ return f"#{rgb_color}"
+ except:
+ return None
+
+
+def get_text_style(run):
+ """Extract inline text styling from a run."""
+ styles = []
+ try:
+ if run.font.bold:
+ styles.append("font-weight:bold")
+ if run.font.italic:
+ styles.append("font-style:italic")
+ if run.font.underline:
+ styles.append("text-decoration:underline")
+ if run.font.size:
+ styles.append(f"font-size:{run.font.size.pt}pt")
+ if run.font.color and run.font.color.rgb:
+ styles.append(f"color:{rgb_to_hex(run.font.color.rgb)}")
+ if run.font.name:
+ styles.append(f"font-family:'{run.font.name}',sans-serif")
+ except:
+ pass
+ return ";".join(styles)
+
+
+def get_alignment(paragraph):
+ """Get CSS text-align from paragraph alignment."""
+ try:
+ align = paragraph.alignment
+ if align == PP_ALIGN.CENTER:
+ return "center"
+ elif align == PP_ALIGN.RIGHT:
+ return "right"
+ elif align == PP_ALIGN.JUSTIFY:
+ return "justify"
+ except:
+ pass
+ return "left"
+
+
+def extract_image(shape):
+ """Extract image from shape as base64 data URI."""
+ try:
+ image = shape.image
+ content_type = image.content_type
+ image_bytes = image.blob
+ b64 = base64.b64encode(image_bytes).decode('utf-8')
+ return f"data:{content_type};base64,{b64}"
+ except:
+ return None
+
+
+def get_shape_position(shape, slide_width, slide_height):
+ """Get shape position as percentages."""
+ try:
+ left = (shape.left / slide_width) * 100 if shape.left else 0
+ top = (shape.top / slide_height) * 100 if shape.top else 0
+ width = (shape.width / slide_width) * 100 if shape.width else 50
+ height = (shape.height / slide_height) * 100 if shape.height else 30
+ return left, top, width, height
+ except:
+ return 5, 5, 90, 40
+
+
+def get_slide_background(slide, prs):
+ """Extract slide background color from XML."""
+ from pptx.oxml.ns import qn
+ for source in [slide, slide.slide_layout]:
+ try:
+ bg_el = source.background._element
+ # Look for solidFill > srgbClr inside bgPr
+ for sf in bg_el.iter(qn('a:solidFill')):
+ clr = sf.find(qn('a:srgbClr'))
+ if clr is not None and clr.get('val'):
+ return f"background-color:#{clr.get('val')}"
+ except:
+ pass
+ return "background-color:#ffffff"
+
+
+def get_shape_fill(shape):
+ """Extract shape fill color from XML."""
+ from pptx.oxml.ns import qn
+ try:
+ sp_pr = shape._element.find(qn('p:spPr'))
+ if sp_pr is None:
+ sp_pr = shape._element.find(qn('a:spPr'))
+ if sp_pr is None:
+ # Try direct child
+ for tag in ['{http://schemas.openxmlformats.org/drawingml/2006/main}spPr',
+ '{http://schemas.openxmlformats.org/presentationml/2006/main}spPr']:
+ sp_pr = shape._element.find(tag)
+ if sp_pr is not None:
+ break
+ if sp_pr is not None:
+ sf = sp_pr.find(qn('a:solidFill'))
+ if sf is not None:
+ clr = sf.find(qn('a:srgbClr'))
+ if clr is not None and clr.get('val'):
+ return f"#{clr.get('val')}"
+ except:
+ pass
+ return None
+
+
+def render_paragraph(paragraph):
+ """Render a paragraph with inline formatting."""
+ align = get_alignment(paragraph)
+ parts = []
+ for run in paragraph.runs:
+ text = run.text
+ if not text:
+ continue
+ text = text.replace("&", "&").replace("<", "<").replace(">", ">")
+ style = get_text_style(run)
+ if style:
+ parts.append(f'{text}')
+ else:
+ parts.append(text)
+ if not parts:
+ return ""
+ content = "".join(parts)
+ return f'{content}
'
+
+
+def convert(pptx_path, output_path=None):
+ prs = Presentation(pptx_path)
+ slide_width = prs.slide_width
+ slide_height = prs.slide_height
+ aspect_ratio = slide_width / slide_height if slide_height else 16/9
+
+ slides_html = []
+
+ for i, slide in enumerate(prs.slides, 1):
+ bg_style = get_slide_background(slide, prs)
+ elements = []
+
+ for shape in sorted(slide.shapes, key=lambda s: (s.top or 0, s.left or 0)):
+ left, top, width, height = get_shape_position(shape, slide_width, slide_height)
+ pos_style = f"position:absolute;left:{left:.1f}%;top:{top:.1f}%;width:{width:.1f}%;height:{height:.1f}%"
+
+ # Image
+ if shape.shape_type == 13 or hasattr(shape, "image"):
+ data_uri = extract_image(shape)
+ if data_uri:
+ elements.append(
+ f''
+ f'

'
+ f'
'
+ )
+ continue
+
+ # Table
+ if shape.has_table:
+ table = shape.table
+ table_html = ''
+ for row in table.rows:
+ table_html += ""
+ for cell in row.cells:
+ cell_text = cell.text.replace("&", "&").replace("<", "<")
+ table_html += f'| {cell_text} | '
+ table_html += "
"
+ table_html += "
"
+ elements.append(f'{table_html}
')
+ continue
+
+ # Text
+ if shape.has_text_frame:
+ text_parts = []
+ for para in shape.text_frame.paragraphs:
+ rendered = render_paragraph(para)
+ if rendered:
+ text_parts.append(rendered)
+ if text_parts:
+ content = "".join(text_parts)
+ fill = get_shape_fill(shape)
+ fill_style = f"background-color:{fill};padding:1em;border-radius:8px;" if fill else ""
+ elements.append(
+ f''
+ f'{content}
'
+ )
+ continue
+
+ # Decorative shape with fill (colored rectangles, bars, etc.)
+ fill = get_shape_fill(shape)
+ if fill:
+ elements.append(
+ f''
+ )
+
+ slide_content = "\n".join(elements)
+ slides_html.append(
+ f''
+ )
+
+ title = "Presentation"
+ # Try to get title from first slide
+ if prs.slides:
+ for shape in prs.slides[0].shapes:
+ if hasattr(shape, "text") and shape.text.strip() and len(shape.text.strip()) < 150:
+ title = shape.text.strip()
+ break
+
+ html = f'''
+
+
+
+
+{title}
+
+
+
+{chr(10).join(slides_html)}
+
+
+
+'''
+
+ output = output_path or str(Path(pptx_path).with_suffix('.html'))
+ Path(output).write_text(html, encoding='utf-8')
+ print(f"Converted to: {output}")
+ print(f"Slides: {len(slides_html)}")
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: convert-pptx.py [output.html]")
+ sys.exit(1)
+ convert(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)
diff --git a/skills/publish-to-pages/scripts/publish.sh b/skills/publish-to-pages/scripts/publish.sh
new file mode 100755
index 000000000..ddebfc6fc
--- /dev/null
+++ b/skills/publish-to-pages/scripts/publish.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+# Main publish script
+# Args: $1 = path to index.html, $2 = repo name, $3 = visibility (private|public), $4 = description
+set -euo pipefail
+
+HTML_FILE="$1"
+REPO_NAME="$2"
+VISIBILITY="${3:-public}"
+DESCRIPTION="${4:-Published via publish-to-pages}"
+
+USERNAME=$(gh api user --jq '.login')
+
+# Check if repo exists
+if gh repo view "$USERNAME/$REPO_NAME" &>/dev/null; then
+ echo "ERROR: Repository $USERNAME/$REPO_NAME already exists"
+ exit 1
+fi
+
+# Create repo
+gh repo create "$REPO_NAME" --"$VISIBILITY" --description "$DESCRIPTION"
+
+# Clone, push, enable pages
+TMPDIR=$(mktemp -d)
+git clone "https://github.com/$USERNAME/$REPO_NAME.git" "$TMPDIR"
+cp "$HTML_FILE" "$TMPDIR/index.html"
+cd "$TMPDIR"
+git add index.html
+git commit -m "Publish content"
+git push origin main
+
+# Enable GitHub Pages
+gh api "repos/$USERNAME/$REPO_NAME/pages" -X POST -f source[branch]=main -f source[path]=/ 2>/dev/null || true
+
+echo "REPO_URL=https://github.com/$USERNAME/$REPO_NAME"
+echo "PAGES_URL=https://$USERNAME.github.io/$REPO_NAME/"
+echo ""
+echo "GitHub Pages may take 1-2 minutes to deploy."
+
+# Cleanup
+rm -rf "$TMPDIR"