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
155 changes: 155 additions & 0 deletions .github/scripts/build_power.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""POWER.md generator for inline plugins (Kiro Power).

Single source of truth is the plugin's skills/<name>/SKILL.md. This emits
<plugin_dir>/POWER.md so the inline skill installs as a Kiro Power. Pure,
deterministic, idempotent. POWER.md is a GENERATED, CI-owned artifact - same
contract as .codex-plugin/plugin.json: never hand-edit. The release pipeline
regenerates it; validate.yml runs --check to block drift.

displayName + keywords are reused from the plugin's .codex-plugin/plugin.json
(one curated source). references/ files are NOT moved; only relative links are
rewritten so they resolve from the plugin (power) root. If <plugin_dir>/mcp.json
exists, an "## MCP Tools (Kiro)" trailer naming its servers is appended.

Stdlib only (no PyYAML): the SKILL.md frontmatter is a fixed, simple shape
parsed line by line - same approach as .github/scripts/update_external_plugins.py.

Usage:
python3 .github/scripts/build_power.py plugins/<plugin> # write
python3 .github/scripts/build_power.py plugins/<plugin> --check # verify
"""

import json
import os
import re
import sys


def parse_frontmatter(skill_path):
src = open(skill_path, encoding="utf-8").read()
if not src.startswith("---"):
raise SystemExit(f"build_power: {skill_path} has no leading --- "
f"frontmatter")
parts = src.split("---")
fm, body = parts[1], "---".join(parts[2:])
out = {}
for raw in fm.splitlines():
line = raw.rstrip("\r")
m = re.match(r"^name:\s*(.+?)\s*$", line)
if m:
out["name"] = m.group(1)
continue
m = re.match(r"^description:\s*(.+?)\s*$", line)
if m:
out["description"] = m.group(1)
continue
m = re.match(r"^\s+author:\s*(.+?)\s*$", line)
if m:
out["author"] = m.group(1)
continue
m = re.match(r"^\s+version:\s*(.+?)\s*$", line)
if m:
out["version"] = m.group(1)
continue
for k in ("name", "description", "author", "version"):
if not out.get(k):
raise SystemExit(f"build_power: {skill_path} frontmatter "
f"missing {k}")
return out, body


def yaml_dq(s):
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'


def build_power(plugin_dir):
plugin_dir = plugin_dir.rstrip("/")
name = os.path.basename(plugin_dir)
skill_path = os.path.join(plugin_dir, "skills", name, "SKILL.md")
codex_path = os.path.join(plugin_dir, ".codex-plugin", "plugin.json")
mcp_path = os.path.join(plugin_dir, "mcp.json")

meta, body = parse_frontmatter(skill_path)

if not os.path.isfile(codex_path):
raise SystemExit(f"build_power: {codex_path} not found (needed for "
f"displayName/keywords)")
codex = json.load(open(codex_path, encoding="utf-8"))
display_name = (codex.get("interface") or {}).get("displayName") \
or meta["name"]
keywords = codex.get("keywords") or []
if not keywords:
raise SystemExit(f"build_power: {codex_path} has no keywords")

rewritten = re.sub(r"\]\(references/",
f"](skills/{name}/references/", body)
rewritten = re.sub(r"^\n+", "", rewritten)
rewritten = re.sub(r"\s*$", "", rewritten)

# Quote free-text scalars + every keyword so a future value containing a
# YAML-sensitive char (:, #, [, etc.) cannot break frontmatter parsing.
# version stays unquoted: a CI-controlled multi-dot semver is always a
# YAML string and validate.yml reads it directly.
front = "\n".join([
"---",
f"name: {yaml_dq(meta['name'])}",
f"displayName: {yaml_dq(display_name)}",
f"description: {yaml_dq(meta['description'])}",
f"keywords: [{', '.join(yaml_dq(k) for k in keywords)}]",
f"author: {yaml_dq(meta['author'])}",
f"version: {meta['version']}",
"---",
])

banner = (
"<!-- GENERATED FILE - DO NOT EDIT. Source: "
f"{skill_path}. Regenerate: python3 .github/scripts/build_power.py "
f"{plugin_dir}. CI-owned (version sync), like "
".codex-plugin/plugin.json. -->"
)

doc = f"{front}\n\n{banner}\n\n{rewritten}\n"

if os.path.isfile(mcp_path):
mcp = json.load(open(mcp_path, encoding="utf-8"))
servers = list((mcp.get("mcpServers") or {}).keys())
if servers:
names = ", ".join(f"`{s}`" for s in servers)
doc += (
"\n## MCP Tools (Kiro)\n\n"
f"This Power bundles {names} (see `mcp.json`). Kiro registers "
"it under the Powers section of `~/.kiro/settings/mcp.json` "
"on install. The guidance above works without it.\n"
)
return doc


def main(argv):
args = [a for a in argv if a != "--check"]
check = "--check" in argv
if len(args) != 1:
raise SystemExit("usage: build_power.py <plugin_dir> [--check]")
plugin_dir = args[0]
generated = build_power(plugin_dir)
dest = os.path.join(plugin_dir.rstrip("/"), "POWER.md")
current = open(dest, encoding="utf-8").read() \
if os.path.isfile(dest) else None

if check:
if current != generated:
print(f"build_power: {dest} is out of sync with SKILL.md. "
f"Run: python3 .github/scripts/build_power.py {plugin_dir}")
sys.exit(1)
print(f"build_power: {dest} in sync")
return
if current == generated:
print(f"build_power: {dest} already up to date")
return
with open(dest, "w", encoding="utf-8") as f:
f.write(generated)
print(f"build_power: wrote {dest}")


if __name__ == "__main__":
main(sys.argv[1:])
20 changes: 19 additions & 1 deletion .github/scripts/update_external_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
if newer than the pinned ref, rewrite atomically:
- .claude-plugin: entry.source.ref = vX.Y.Z AND entry.version = X.Y.Z
- .agents: entry.source.ref = vX.Y.Z (no version field)
- .kiro: entry.source.ref = vX.Y.Z (no version field; mirror of
.agents - Kiro does not consume this manifest for install
today, it is kept in sync for pattern parity / drift guard)

No third-party deps (stdlib only). Real errors exit non-zero; "nothing to do"
exits 0. `--dry-run` prints intended changes without writing.
Expand All @@ -33,6 +36,7 @@

CLAUDE_MANIFEST = ".claude-plugin/marketplace.json"
AGENTS_MANIFEST = ".agents/plugins/marketplace.json"
KIRO_MANIFEST = ".kiro/plugins/marketplace.json"
POLICY_FILE = ".github/external-plugin-updates.json"

DEFAULTS = {
Expand Down Expand Up @@ -159,6 +163,8 @@ def main() -> int:
claude = load_json(CLAUDE_MANIFEST)
agents = load_json(AGENTS_MANIFEST)
agents_by_name = {p.get("name"): p for p in agents.get("plugins", [])}
kiro = load_json(KIRO_MANIFEST)
kiro_by_name = {p.get("name"): p for p in kiro.get("plugins", [])}

changed = []
for entry in claude.get("plugins", []):
Expand All @@ -179,6 +185,15 @@ def main() -> int:
fail(f"{name}: {AGENTS_MANIFEST} repo {a_repo!r} != "
f"{repo!r} ({CLAUDE_MANIFEST})")

k = kiro_by_name.get(name)
if k is None:
fail(f"{name}: no matching entry in {KIRO_MANIFEST}")
k_src = k.get("source", {})
k_repo = normalize_repo(k_src.get("url", ""))
if k_repo.lower() != repo.lower():
fail(f"{name}: {KIRO_MANIFEST} repo {k_repo!r} != "
f"{repo!r} ({CLAUDE_MANIFEST})")

cfg = {**pdefaults, **(poverrides.get(name) or {})}
ver, tag = latest_version(repo, cfg)
cur_ref = src.get("ref", "")
Expand All @@ -192,6 +207,7 @@ def main() -> int:
src["ref"] = tag
entry["version"] = ver # mirrored, no leading v
a_src["ref"] = tag # .agents: ref only
k_src["ref"] = tag # .kiro: ref only (mirror)

if not changed:
print("Nothing to update.")
Expand All @@ -207,7 +223,9 @@ def main() -> int:

write_json(CLAUDE_MANIFEST, claude)
write_json(AGENTS_MANIFEST, agents)
print(f"\nUpdated {CLAUDE_MANIFEST} and {AGENTS_MANIFEST}.")
write_json(KIRO_MANIFEST, kiro)
print(f"\nUpdated {CLAUDE_MANIFEST}, {AGENTS_MANIFEST}, "
f"and {KIRO_MANIFEST}.")
return 0


Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/automated-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ name: Automated Release
# - bumps plugins[].version in marketplace.json
# - syncs that plugin's SKILL.md metadata.version
# - syncs that plugin's .codex-plugin/plugin.json version (if present)
# - regenerates that plugin's POWER.md (Kiro) from SKILL.md (if present)
# - prepends an entry to plugins/<plugin>/CHANGELOG.md
# - tags <plugin>-vX.Y.Z and creates a GitHub Release

Expand Down Expand Up @@ -194,6 +195,17 @@ jobs:
open(codex_path, 'a').write('\n')
print(f" synced {codex_path} -> {new}")

# 2c. Regenerate the Kiro POWER.md from the now-bumped
# SKILL.md (only if the plugin ships one - it needs a
# .codex-plugin/plugin.json for displayName/keywords). The
# commit step stages all of plugins/, so it lands in the
# release commit + tag, same as SKILL.md/codex.
if os.path.isfile(os.path.join(source, 'POWER.md')):
subprocess.run(
['python3', '.github/scripts/build_power.py', source],
check=True)
print(f" regenerated {source}/POWER.md -> {new}")

# 3. plugins/<plugin>/CHANGELOG.md entry.
# Markdownlint-clean and idempotent: a stable `# Changelog`
# H1 + preamble stays on top, new version sections are
Expand Down
19 changes: 17 additions & 2 deletions .github/workflows/update-external-plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ jobs:
import json, re, sys
claude = json.load(open('.claude-plugin/marketplace.json'))
agents = json.load(open('.agents/plugins/marketplace.json'))
kiro = json.load(open('.kiro/plugins/marketplace.json'))
a_by_name = {p.get('name'): p for p in agents.get('plugins', [])}
k_by_name = {p.get('name'): p for p in kiro.get('plugins', [])}

def norm(url):
m = re.match(r'^git@github\.com:(.+?)(?:\.git)?$', (url or '').strip())
Expand Down Expand Up @@ -71,6 +73,17 @@ jobs:
errors.append(
f"{name}: ref mismatch claude {ref!r} vs "
f".agents {a_src.get('ref','')!r}")
k = k_by_name.get(name)
if k is None:
errors.append(f"{name}: missing from .kiro manifest")
continue
k_src = k.get('source', {})
if norm(k_src.get('url', '')) != repo.lower():
errors.append(f"{name}: .kiro repo mismatch")
if k_src.get('ref', '') != ref:
errors.append(
f"{name}: ref mismatch claude {ref!r} vs "
f".kiro {k_src.get('ref','')!r}")

if errors:
print("\n".join(f"❌ {x}" for x in errors))
Expand All @@ -89,8 +102,9 @@ jobs:
body: |
Automated external-plugin pin update.

Bumps `source.ref` in both manifests (and the mirrored
`version` in `.claude-plugin/marketplace.json`) to the latest
Bumps `source.ref` in all three manifests (`.claude-plugin`,
`.agents`, `.kiro`) plus the mirrored `version` in
`.claude-plugin/marketplace.json` to the latest
eligible upstream release. Manifest-only `chore:` change - no
agent-plugins release is triggered.

Expand All @@ -99,6 +113,7 @@ jobs:
add-paths: |
.claude-plugin/marketplace.json
.agents/plugins/marketplace.json
.kiro/plugins/marketplace.json

- name: Auto-merge
# The inline "Validate manifest sync" step above is the gate: if it
Expand Down
Loading
Loading