Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Global code owner
* @mnriem

# Community catalog files — require maintainer approval even for bot PRs
extensions/catalog.community.json @mnriem
integrations/catalog.community.json @mnriem
presets/catalog.community.json @mnriem

18 changes: 15 additions & 3 deletions .github/ISSUE_TEMPLATE/preset_submission.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,22 @@ body:
validations:
required: true

- type: input
id: required-extensions
attributes:
label: Required Extensions (optional)
description: Comma-separated list of required extension IDs (e.g., aide)
placeholder: "e.g., aide, canon"

- type: textarea
id: templates-provided
attributes:
label: Templates Provided
description: List the template overrides your preset provides
description: List the template overrides your preset provides (leave empty for command-only presets)
placeholder: |
- spec-template.md — adds compliance section
- plan-template.md — includes audit checkpoints
- checklist-template.md — HIPAA compliance checklist
validations:
required: true

- type: textarea
id: commands-provided
Expand All @@ -115,6 +120,13 @@ body:
placeholder: |
- speckit.specify.md — customized for compliance workflows

- type: input
id: scripts-count
attributes:
label: Number of Scripts (optional)
description: How many scripts does your preset provide? (leave empty if none)
placeholder: "e.g., 1"

- type: textarea
id: tags
attributes:
Expand Down
208 changes: 208 additions & 0 deletions .github/scripts/catalog-generate-table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Generate a markdown table from a community catalog JSON file.

Reads a catalog.community.json and replaces content between marker comments
in a target markdown file. When ``--target`` is provided and markers are
missing, the script exits with an error. Without ``--target`` the table is
printed to stdout.

Markers expected in the markdown file:
<!-- catalog-table-start -->
... (old table content replaced) ...
<!-- catalog-table-end -->

Usage:
python .github/scripts/catalog-generate-table.py \
--catalog presets/catalog.community.json \
--type preset \
--target docs/community/presets.md
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

START_MARKER = "<!-- catalog-table-start -->"
END_MARKER = "<!-- catalog-table-end -->"


Comment thread
mnriem marked this conversation as resolved.
# ---------------------------------------------------------------------------
# Table builders — one per catalog type
# ---------------------------------------------------------------------------

def _repo_display_name(url: str) -> str:
"""Extract the repository name from a GitHub URL."""
# https://github.com/user/spec-kit-foo → spec-kit-foo
return url.rstrip("/").rsplit("/", 1)[-1]


def _provides_str_preset(provides: dict) -> str:
parts: list[str] = []
t = provides.get("templates", 0)
c = provides.get("commands", 0)
s = provides.get("scripts", 0)
if t:
parts.append(f"{t} template{'s' if t != 1 else ''}")
if c:
parts.append(f"{c} command{'s' if c != 1 else ''}")
if s:
parts.append(f"{s} script{'s' if s != 1 else ''}")
return ", ".join(parts) or "—"


def _requires_str_preset(requires: dict) -> str:
exts = requires.get("extensions", [])
if exts:
return ", ".join(f"{e} extension" for e in exts)
return "—"


def _escape_cell(value: str) -> str:
"""Escape pipe characters and collapse whitespace for markdown table cells."""
return value.replace("|", "\\|").replace("\n", " ").replace("\r", "").strip()


def build_preset_table(catalog: dict) -> str:
"""Build a markdown table for presets."""
entries = catalog.get("presets", {})
lines: list[str] = []
lines.append("| Preset | Purpose | Provides | Requires | URL |")
lines.append("|--------|---------|----------|----------|-----|")

for _id in sorted(entries):
e = entries[_id]
name = _escape_cell(e.get("name", _id))
desc = _escape_cell(e.get("description", ""))
provides = _provides_str_preset(e.get("provides", {}))
requires = _requires_str_preset(e.get("requires", {}))
repo_url = e.get("repository", "")
repo_name = _repo_display_name(repo_url)
lines.append(
f"| {name} | {desc} | {provides} | {requires} "
f"| [{repo_name}]({repo_url}) |"
)
Comment thread
mnriem marked this conversation as resolved.

return "\n".join(lines)


def _provides_str_extension(provides: dict) -> str:
parts: list[str] = []
c = provides.get("commands", 0)
h = provides.get("hooks", 0)
if c:
parts.append(f"{c} command{'s' if c != 1 else ''}")
if h:
parts.append(f"{h} hook{'s' if h != 1 else ''}")
return ", ".join(parts) or "—"


def build_extension_table(catalog: dict) -> str:
"""Build a markdown table for extensions.

Note: Category and Effect columns will be empty unless the catalog
entries include ``category`` and ``effect`` fields (not yet part of
the standard catalog schema).
"""
entries = catalog.get("extensions", {})
lines: list[str] = []
lines.append("| Extension | Purpose | Category | Effect | URL |")
lines.append("|-----------|---------|----------|--------|-----|")

for _id in sorted(entries):
e = entries[_id]
name = _escape_cell(e.get("name", _id))
desc = _escape_cell(e.get("description", ""))
category = _escape_cell(e.get("category", ""))
if category:
category = f"`{category}`"
effect = _escape_cell(e.get("effect", ""))
repo_url = e.get("repository", "")
repo_name = _repo_display_name(repo_url)
lines.append(
f"| {name} | {desc} | {category} | {effect} "
f"| [{repo_name}]({repo_url}) |"
)
Comment thread
mnriem marked this conversation as resolved.

Comment thread
mnriem marked this conversation as resolved.
return "\n".join(lines)


BUILDERS = {
"preset": build_preset_table,
"extension": build_extension_table,
}


# ---------------------------------------------------------------------------
# File updater
# ---------------------------------------------------------------------------

def update_file(path: Path, table: str) -> bool:
"""Replace content between markers in *path*. Returns True if updated."""
content = path.read_text()

pattern = re.compile(
rf"({re.escape(START_MARKER)})\n.*?\n({re.escape(END_MARKER)})",
re.DOTALL,
)

if not pattern.search(content):
return False

new_content = pattern.sub(lambda m: f"{m.group(1)}\n{table}\n{m.group(2)}", content)

if new_content != content:
path.write_text(new_content)
return True
Comment thread
mnriem marked this conversation as resolved.
return False


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--catalog", required=True,
help="Path to catalog.community.json",
)
parser.add_argument(
"--type", required=True, choices=list(BUILDERS),
help="Catalog type",
)
parser.add_argument(
"--target",
help="Markdown file to update (must contain marker comments)",
)
args = parser.parse_args()

with open(args.catalog) as f:
catalog = json.load(f)

builder = BUILDERS[args.type]
table = builder(catalog)

if args.target:
target = Path(args.target)
if not target.exists():
print(f"Error: target file not found: {target}", file=sys.stderr)
sys.exit(1)
if update_file(target, table):
print(f"Updated {target}")
else:
print(
f"Error: markers {START_MARKER} / {END_MARKER} not found "
f"in {target}.",
file=sys.stderr,
)
Comment thread
mnriem marked this conversation as resolved.
sys.exit(1)
else:
print(table)


if __name__ == "__main__":
main()
138 changes: 138 additions & 0 deletions .github/scripts/catalog-pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Update a community catalog JSON file with a validated entry.

Reads the validation result and entry produced by catalog-validate.py,
inserts or replaces the entry in the catalog, sorts entries alphabetically,
and optionally regenerates a docs table.

Usage (typically called from GitHub Actions):
python .github/scripts/catalog-pr.py \
--catalog extensions/catalog.community.json \
--type extension

python .github/scripts/catalog-pr.py \
--catalog presets/catalog.community.json \
--type preset \
--table-target docs/community/presets.md

Environment variables:
GITHUB_OUTPUT — Path to GitHub Actions output file (optional)

Inputs (files produced by catalog-validate.py):
/tmp/validation-result.json — metadata including item_id, valid, is_update
/tmp/catalog-entry.json — the entry to insert/replace
"""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path

CATALOG_KEY = {
"extension": "extensions",
"preset": "presets",
}


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--catalog", required=True,
help="Path to the catalog JSON file",
)
parser.add_argument(
"--type", required=True, choices=list(CATALOG_KEY),
help="Catalog type",
)
parser.add_argument(
"--result", default="/tmp/validation-result.json",
help="Path to validation result JSON",
)
parser.add_argument(
"--entry", default="/tmp/catalog-entry.json",
help="Path to catalog entry JSON",
)
parser.add_argument(
"--table-target",
help="Markdown file to regenerate table in (must contain marker comments)",
)
args = parser.parse_args()

# Load validation result
result_path = Path(args.result)
if not result_path.exists():
print(f"Error: result file not found: {result_path}", file=sys.stderr)
sys.exit(2)
result = json.loads(result_path.read_text())

if not result.get("valid"):
print("Submission is not valid — skipping catalog update.")
_set_output("skipped", "true")
sys.exit(0)

# Load entry
entry_path = Path(args.entry)
if not entry_path.exists():
print(f"Error: entry file not found: {entry_path}", file=sys.stderr)
sys.exit(2)
new_entry = json.loads(entry_path.read_text())

item_id = result["item_id"]
is_update = result.get("is_update", False)
cat_key = CATALOG_KEY[args.type]

# Update catalog
catalog_path = Path(args.catalog)
with open(catalog_path) as f:
catalog = json.load(f)

catalog[cat_key][item_id] = new_entry
catalog["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
catalog[cat_key] = dict(sorted(catalog[cat_key].items()))

with open(catalog_path, "w") as f:
json.dump(catalog, f, indent=2)
f.write("\n")

print(f"Updated {catalog_path}: {'replaced' if is_update else 'added'} {item_id}")

# Regenerate docs table if requested
if args.table_target:
table_script = Path(__file__).parent / "catalog-generate-table.py"
subprocess.run(
[
sys.executable, str(table_script),
"--catalog", args.catalog,
"--type", args.type,
"--target", args.table_target,
],
check=True,
)

Comment on lines +89 to +116
# Set outputs for the workflow
action = "update" if is_update else "add"
_set_output("skipped", "false")
_set_output("item_id", item_id)
_set_output("is_update", str(is_update).lower())
_set_output("action", action.title())
_set_output("action_verb", f"{action.title()}s")
_set_output("branch", f"community/{action}-{args.type}-{item_id}")


def _set_output(name: str, value: str) -> None:
"""Write a GitHub Actions output variable."""
gh_output = os.environ.get("GITHUB_OUTPUT")
if gh_output:
with open(gh_output, "a") as f:
f.write(f"{name}={value}\n")
# Also print for local debugging
print(f" output: {name}={value}")


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