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
40 changes: 39 additions & 1 deletion hooks/ci-merge-gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import json
import os
import subprocess
import sys
from pathlib import Path
Expand All @@ -29,9 +30,46 @@ def main() -> None:
if "gh pr merge" not in command and "gh pr merge" not in command.replace(" ", " "):
return

parts = command.split()

# --- Block --admin before any CI check ---
if "--admin" in parts:
if os.environ.get("ALLOW_ADMIN_MERGE") == "1":
print("[ci-merge-gate] WARNING: --admin override allowed via ALLOW_ADMIN_MERGE=1", file=sys.stderr)
else:
print("[ci-merge-gate] BLOCKED: --admin bypasses branch protection", file=sys.stderr)
deny_output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"Use of --admin bypasses CI checks. Remove --admin and wait for CI to pass."
),
}
}
print(json.dumps(deny_output))
sys.exit(0)

# --- Block --force before any CI check ---
if "--force" in parts:
if os.environ.get("ALLOW_FORCE_MERGE") == "1":
print("[ci-merge-gate] WARNING: --force override allowed via ALLOW_FORCE_MERGE=1", file=sys.stderr)
else:
print("[ci-merge-gate] BLOCKED: --force bypasses merge safeguards", file=sys.stderr)
deny_output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"Use of --force bypasses merge safeguards. Remove --force and merge normally."
),
}
}
print(json.dumps(deny_output))
sys.exit(0)

# Extract PR number from command
# Patterns: gh pr merge 55, gh pr merge #55, gh pr merge --squash 55
parts = command.split()
pr_number = None
for i, part in enumerate(parts):
if part.lstrip("#").isdigit() and i > 0 and parts[i - 1] != "--count":
Expand Down
104 changes: 104 additions & 0 deletions hooks/tests/test_ci_merge_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Tests for ci-merge-gate.py --admin and --force blocking."""

import json
import os
import subprocess
import sys

import pytest

HOOK = os.path.join(os.path.dirname(__file__), "..", "ci-merge-gate.py")


def run_hook(command: str, env_overrides: dict | None = None) -> subprocess.CompletedProcess:
"""Run the hook with a synthetic PreToolUse event."""
event = json.dumps({"tool_name": "Bash", "tool_input": {"command": command}})
env = os.environ.copy()
# Clear escape hatches by default
env.pop("ALLOW_ADMIN_MERGE", None)
env.pop("ALLOW_FORCE_MERGE", None)
if env_overrides:
env.update(env_overrides)
return subprocess.run(
[sys.executable, HOOK],
input=event,
capture_output=True,
text=True,
timeout=15,
env=env,
)


class TestAdminBlock:
def test_admin_flag_denied(self):
r = run_hook("gh pr merge 55 --admin")
assert r.returncode == 0
out = json.loads(r.stdout.strip().split("\n")[-1])
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
assert "--admin" in out["hookSpecificOutput"]["permissionDecisionReason"]

def test_admin_allowed_with_env(self):
r = run_hook("gh pr merge 55 --admin", env_overrides={"ALLOW_ADMIN_MERGE": "1"})
assert r.returncode == 0
# Should NOT contain a deny decision
for line in r.stdout.strip().split("\n"):
line = line.strip()
if line.startswith("{"):
data = json.loads(line)
decision = data.get("hookSpecificOutput", {}).get("permissionDecision")
assert decision != "deny", "Should not deny when ALLOW_ADMIN_MERGE=1"
# Should warn on stderr
assert "ALLOW_ADMIN_MERGE" in r.stderr


class TestForceBlock:
def test_force_flag_denied(self):
r = run_hook("gh pr merge 55 --force")
assert r.returncode == 0
out = json.loads(r.stdout.strip().split("\n")[-1])
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
assert "--force" in out["hookSpecificOutput"]["permissionDecisionReason"]

def test_force_allowed_with_env(self):
r = run_hook("gh pr merge 55 --force", env_overrides={"ALLOW_FORCE_MERGE": "1"})
assert r.returncode == 0
for line in r.stdout.strip().split("\n"):
line = line.strip()
if line.startswith("{"):
data = json.loads(line)
decision = data.get("hookSpecificOutput", {}).get("permissionDecision")
assert decision != "deny", "Should not deny when ALLOW_FORCE_MERGE=1"
assert "ALLOW_FORCE_MERGE" in r.stderr


class TestPassthrough:
def test_normal_merge_passes(self):
"""Normal merge should not be blocked by admin/force checks."""
r = run_hook("gh pr merge 55 --squash")
assert r.returncode == 0
# Should not have a deny from admin/force (CI check may still fire but that's separate)
for line in r.stdout.strip().split("\n"):
line = line.strip()
if line.startswith("{"):
data = json.loads(line)
reason = data.get("hookSpecificOutput", {}).get("permissionDecisionReason", "")
assert "--admin" not in reason
assert "--force" not in reason

def test_non_merge_command_passes(self):
"""Non-merge commands should pass through completely."""
r = run_hook("gh pr view 55")
assert r.returncode == 0
assert r.stdout.strip() == "" or "deny" not in r.stdout

def test_merge_with_delete_branch_passes(self):
"""Normal merge flags should not trigger admin/force block."""
r = run_hook("gh pr merge 55 --squash --delete-branch")
assert r.returncode == 0
for line in r.stdout.strip().split("\n"):
line = line.strip()
if line.startswith("{"):
data = json.loads(line)
reason = data.get("hookSpecificOutput", {}).get("permissionDecisionReason", "")
assert "--admin" not in reason
assert "--force" not in reason
1 change: 1 addition & 0 deletions scripts/migrate-skills-to-folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"workflow-help": "meta",
"reference-enrichment": "meta",
"generate-claudemd": "meta",
"html-artifact": "meta",
"docs-sync-checker": "meta",
"explanation-traces": "meta",
# process/ — methodologies, git, debugging
Expand Down
1 change: 1 addition & 0 deletions skills/meta/do/references/routing-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Route to these agents based on the user's task domain. Each entry describes what
| **wordpress-live-validation** | User wants to validate WordPress posts live after upload: check rendering, canonical URLs, or publication status. |
| **pptx-generator** | User wants to generate a PowerPoint presentation, slide deck, or pitch deck from content or research. |
| **frontend-slides** | User wants browser-based HTML presentations: reveal-style slide decks, kiosk presentations, or converting PPTX to web format. |
| **html-artifact** | User wants rich self-contained HTML output instead of markdown — specs, code reviews, reports, prototypes, editors, data viz. Auto-detected by router or invoked via `/html`. NOT: web applications, React/Vue projects, multi-page sites (use typescript-frontend-engineer). NOT: slide decks (use frontend-slides). NOT: interactive essays (use interactive-essay). |
| **gemini-image-generator** | User wants to generate images from text prompts via Google Gemini: sprites, character art, or AI-generated visuals. |
| **bluesky-reader** | User wants to read public Bluesky feeds, fetch posts, or interact with the AT Protocol API. |
| **image-to-video** | User wants to combine a static image with audio to create a video file (album art video, podcast video, music visualization). |
Expand Down
125 changes: 125 additions & 0 deletions skills/meta/html-artifact/scripts/assemble-template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Deterministic HTML template assembler for html-artifact skill.

Given a shape and title, injects the correct theme CSS into the base template
and outputs a ready-to-fill HTML skeleton to stdout.

Exit codes:
0: template assembled successfully
1: invalid shape or theme
2: base template not found

Usage:
python3 skills/meta/html-artifact/scripts/assemble-template.py --shape spec --title "Auth Comparison"
python3 skills/meta/html-artifact/scripts/assemble-template.py --shape code-review --title "PR #42" --theme dark-focus
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz")
VALID_THEMES = ("birchline", "dark-focus", "interactive-warm", "minimal-document")

SHAPE_DEFAULT_THEME: dict[str, str] = {
"spec": "birchline",
"code-review": "dark-focus",
"prototype": "interactive-warm",
"report": "birchline",
"editor": "interactive-warm",
"data-viz": "dark-focus",
}

# Theme CSS overrides. birchline is the base template default — no override needed.
THEME_CSS: dict[str, str] = {
"birchline": "",
"dark-focus": (
" /* === Dark Focus Theme Override === */\n"
" :root { --color-primary: #64B5F6; --color-slate: #e0e0e0; --color-ivory: #1a1a2e;"
" --color-oat: #232340; --color-white: #2a2a4a; --color-gray-100: #2d2d50;"
" --color-gray-300: #3d3d60; --color-gray-500: #8888a0; --color-gray-700: #c0c0d0;"
" --color-success: #81C784; --color-warning: #FFB74D; --color-danger: #E57373;"
" --color-info: #64B5F6; }\n"
" body { background: var(--color-ivory); color: var(--color-slate); }\n"
),
"interactive-warm": (
" /* === Interactive Warm Theme Override === */\n"
" :root { --color-primary: #5B8DEF; --color-ivory: #FAFAF8; --color-oat: #F0F0EC; }\n"
),
"minimal-document": (
" /* === Minimal Document Theme Override === */\n"
" :root { --color-ivory: #FFFFF8; --color-slate: #333333; --color-primary: #555555; }\n"
" body { font-family: Georgia, 'Times New Roman', serif; max-width: 680px;"
" margin: 0 auto; line-height: 1.7; }\n"
),
}

BASE_TEMPLATE_PATH = Path(__file__).parent.parent / "assets" / "base-template.html"


def assemble_template(shape: str, title: str, theme: str | None = None) -> str:
"""Assemble an HTML template with the given shape, title, and theme.

Args:
shape: One of the 6 valid artifact shapes.
title: The title to inject into the template.
theme: Optional theme override. Defaults to shape-specific theme.

Returns:
The assembled HTML string.

Raises:
ValueError: If shape or theme is invalid.
FileNotFoundError: If base template is missing.
"""
if shape not in VALID_SHAPES:
raise ValueError(f"Invalid shape '{shape}'. Valid shapes: {', '.join(VALID_SHAPES)}")

resolved_theme = theme if theme is not None else SHAPE_DEFAULT_THEME[shape]
if resolved_theme not in VALID_THEMES:
raise ValueError(f"Invalid theme '{resolved_theme}'. Valid themes: {', '.join(VALID_THEMES)}")

template = BASE_TEMPLATE_PATH.read_text(encoding="utf-8")

# Inject title
html = template.replace("<!-- TITLE -->", title)

# Inject theme CSS override after the STYLES comment placeholder
theme_css = THEME_CSS[resolved_theme]
if theme_css:
html = html.replace("/* <!-- STYLES --> */", theme_css + " /* <!-- STYLES --> */")

return html


def main() -> None:
"""CLI entry point."""
parser = argparse.ArgumentParser(description="Assemble an HTML artifact template.")
parser.add_argument("--shape", required=True, help="Artifact shape.")
parser.add_argument("--title", required=True, help="Title for the artifact.")
parser.add_argument(
"--theme", default=None, help="Theme override (birchline, dark-focus, interactive-warm, minimal-document)."
)
args = parser.parse_args()

if args.shape not in VALID_SHAPES:
sys.stderr.write(f"Error: Invalid shape '{args.shape}'. Valid shapes: {', '.join(VALID_SHAPES)}\n")
sys.exit(1)

if args.theme is not None and args.theme not in VALID_THEMES:
sys.stderr.write(f"Error: Invalid theme '{args.theme}'. Valid themes: {', '.join(VALID_THEMES)}\n")
sys.exit(1)

try:
html = assemble_template(args.shape, args.title, args.theme)
except FileNotFoundError:
sys.stderr.write(f"Error: Base template not found at {BASE_TEMPLATE_PATH}\n")
sys.exit(2)

sys.stdout.write(html)


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