diff --git a/hooks/ci-merge-gate.py b/hooks/ci-merge-gate.py index fcd5c3a8..58bd3dbe 100755 --- a/hooks/ci-merge-gate.py +++ b/hooks/ci-merge-gate.py @@ -8,6 +8,7 @@ """ import json +import os import subprocess import sys from pathlib import Path @@ -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": diff --git a/hooks/tests/test_ci_merge_gate.py b/hooks/tests/test_ci_merge_gate.py new file mode 100755 index 00000000..fb1fd55f --- /dev/null +++ b/hooks/tests/test_ci_merge_gate.py @@ -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 diff --git a/scripts/migrate-skills-to-folders.py b/scripts/migrate-skills-to-folders.py index 2a697fc5..9c53614d 100755 --- a/scripts/migrate-skills-to-folders.py +++ b/scripts/migrate-skills-to-folders.py @@ -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 diff --git a/skills/meta/do/references/routing-tables.md b/skills/meta/do/references/routing-tables.md index 1167f67a..79310a1d 100644 --- a/skills/meta/do/references/routing-tables.md +++ b/skills/meta/do/references/routing-tables.md @@ -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). | diff --git a/skills/meta/html-artifact/scripts/assemble-template.py b/skills/meta/html-artifact/scripts/assemble-template.py new file mode 100644 index 00000000..9cb121d7 --- /dev/null +++ b/skills/meta/html-artifact/scripts/assemble-template.py @@ -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) + + # Inject theme CSS override after the STYLES comment placeholder + theme_css = THEME_CSS[resolved_theme] + if theme_css: + html = html.replace("/* */", theme_css + " /* */") + + 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() diff --git a/skills/meta/html-artifact/scripts/generate-filename.py b/skills/meta/html-artifact/scripts/generate-filename.py new file mode 100644 index 00000000..daf24c4a --- /dev/null +++ b/skills/meta/html-artifact/scripts/generate-filename.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Deterministic filename generator for html-artifact skill. + +Given a request string, produces a kebab-case .html filename. + +Usage: + python3 skills/meta/html-artifact/scripts/generate-filename.py --request "explore 3 approaches to rate limiting" + python3 skills/meta/html-artifact/scripts/generate-filename.py --request "build a dashboard" --shape data-viz +""" + +from __future__ import annotations + +import argparse +import re +import sys + +VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz") + +STOP_WORDS = frozenset( + { + "the", + "a", + "an", + "to", + "for", + "of", + "in", + "on", + "at", + "by", + "with", + "from", + "this", + "that", + "these", + "those", + "my", + "our", + "your", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + "had", + "do", + "does", + "did", + "will", + "would", + "shall", + "should", + "can", + "could", + "may", + "might", + "must", + "need", + "me", + "i", + "we", + "you", + "it", + "he", + "she", + "they", + } +) + +VERB_PREFIXES = frozenset( + { + "explore", + "create", + "make", + "build", + "write", + "generate", + "show", + "help", + "review", + "explain", + "analyze", + "compare", + "visualize", + "prototype", + "design", + "report", + "summarize", + "triage", + "reorder", + "edit", + "tune", + } +) + + +def generate_filename(request: str, shape: str | None = None) -> str: + """Generate a kebab-case .html filename from a request string. + + Args: + request: The user's natural language request. + shape: Optional shape to prepend if not already present. + + Returns: + A kebab-case .html filename. + """ + # Step 1: lowercase + lowered = request.lower() + + # Step 2-3: extract words, remove stop words and verb prefixes + words = re.findall(r"[a-z]+", lowered) + content_words = [w for w in words if w not in STOP_WORDS and w not in VERB_PREFIXES] + + # Step 4: max 4 content words + content_words = content_words[:4] + + # Step 7: fallback if no content words + if not content_words: + if shape: + return f"{shape}-artifact.html" + return "artifact.html" + + filename_base = "-".join(content_words) + + # Step 8: prepend shape if provided and shape word not in filename + if shape: + # Normalize shape for comparison (e.g., "data-viz" -> ["data", "viz"]) + shape_parts = shape.split("-") + if not any(part in content_words for part in shape_parts): + filename_base = f"{shape}-{filename_base}" + + return f"{filename_base}.html" + + +def main() -> None: + """CLI entry point.""" + parser = argparse.ArgumentParser(description="Generate a kebab-case filename from a request.") + parser.add_argument("--request", required=True, help="User request string.") + parser.add_argument("--shape", default=None, help="Optional shape to prepend.") + args = parser.parse_args() + + if args.shape is not None and args.shape not in VALID_SHAPES: + sys.stderr.write(f"Error: Invalid shape '{args.shape}'. Valid shapes: {', '.join(VALID_SHAPES)}\n") + sys.exit(1) + + filename = generate_filename(args.request, args.shape) + sys.stdout.write(filename + "\n") + + +if __name__ == "__main__": + main() diff --git a/skills/meta/html-artifact/scripts/select-references.py b/skills/meta/html-artifact/scripts/select-references.py new file mode 100644 index 00000000..c72206d9 --- /dev/null +++ b/skills/meta/html-artifact/scripts/select-references.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Deterministic reference file selector for html-artifact skill. + +Given a shape, outputs the list of reference files to load. +Replaces LLM table-lookup with a script. + +Exit codes: + 0: valid shape, references returned + 1: invalid shape + +Usage: + python3 skills/meta/html-artifact/scripts/select-references.py --shape spec + python3 skills/meta/html-artifact/scripts/select-references.py --shape report --json-compact +""" + +from __future__ import annotations + +import argparse +import json +import sys + +VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz") + +ALWAYS_LOAD = [ + "references/design-system.md", + "references/interaction-patterns.md", +] + +SHAPE_SPECIFIC: dict[str, str] = { + "spec": "references/shape-spec-exploration.md", + "code-review": "references/shape-code-review.md", + "prototype": "references/shape-design-prototype.md", + "report": "references/shape-report-research.md", + "editor": "references/shape-custom-editor.md", + "data-viz": "references/shape-data-visualization.md", +} + + +def select_references(shape: str) -> dict[str, object]: + """Return reference file lists for a given shape. + + Args: + shape: One of the 6 valid artifact shapes. + + Returns: + Dict with shape, always_load, shape_specific, and all_files keys. + + Raises: + ValueError: If shape is not one of the valid shapes. + """ + if shape not in VALID_SHAPES: + raise ValueError(f"Invalid shape '{shape}'. Valid shapes: {', '.join(VALID_SHAPES)}") + + shape_ref = SHAPE_SPECIFIC[shape] + shape_specific = [shape_ref] + + return { + "shape": shape, + "always_load": list(ALWAYS_LOAD), + "shape_specific": shape_specific, + "all_files": ALWAYS_LOAD + shape_specific, + } + + +def main() -> None: + """CLI entry point.""" + parser = argparse.ArgumentParser(description="Select reference files for an artifact shape.") + parser.add_argument( + "--shape", required=True, help="Artifact shape (spec, code-review, prototype, report, editor, data-viz)." + ) + parser.add_argument("--json-compact", action="store_true", help="Output compact JSON (no indentation).") + args = parser.parse_args() + + try: + result = select_references(args.shape) + except ValueError as e: + error_result = {"error": str(e), "valid_shapes": list(VALID_SHAPES)} + indent = None if args.json_compact else 2 + json.dump(error_result, sys.stderr, indent=indent) + sys.stderr.write("\n") + sys.exit(1) + + indent = None if args.json_compact else 2 + json.dump(result, sys.stdout, indent=indent) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/skills/meta/html-artifact/scripts/tests/test_assemble_template.py b/skills/meta/html-artifact/scripts/tests/test_assemble_template.py new file mode 100644 index 00000000..a628d30f --- /dev/null +++ b/skills/meta/html-artifact/scripts/tests/test_assemble_template.py @@ -0,0 +1,127 @@ +"""Tests for assemble-template.py — deterministic HTML template assembler.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = str(Path(__file__).parent.parent / "assemble-template.py") + +# --- Import module directly for unit tests --- +sys.path.insert(0, str(Path(__file__).parent.parent)) +from importlib import import_module + +assemble_mod = import_module("assemble-template") +assemble_template = assemble_mod.assemble_template + + +class TestAssembleTemplateDirect: + """Unit tests calling assemble_template() directly.""" + + def test_title_injected(self) -> None: + html = assemble_template("spec", "My Title") + assert "