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
177 changes: 177 additions & 0 deletions .github/release/build-power.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
'use strict'

/**
* POWER.md generator (Kiro Power).
*
* Single source of truth is skills/terraform-skill/SKILL.md. This emits a
* repo-root POWER.md so the same skill installs as a Kiro Power ("Add power
* from GitHub"). Pure, deterministic, idempotent.
*
* POWER.md is a GENERATED, CI-owned artifact - same contract as
* .codex-plugin/plugin.json: never hand-edit. The release pre-commit hook
* regenerates and stages it so the tag carries an in-sync tree.
*
* displayName + keywords are reused from .codex-plugin/plugin.json (one
* curated source). references/ files are NOT moved; only relative links are
* rewritten so they resolve from repo root.
*
* Node built-ins only (fs, path). No npm deps. No YAML lib: the SKILL.md
* frontmatter is a fixed, simple shape parsed line by line.
*
* Usage:
* node .github/release/build-power.js # write POWER.md
* node .github/release/build-power.js --check # exit 1 if out of sync
*/

const fs = require('fs')
const path = require('path')

const SKILL_REL = 'skills/terraform-skill/SKILL.md'
const CODEX_REL = '.codex-plugin/plugin.json'
const POWER_REL = 'POWER.md'
const SENTINEL_REL = 'version.json'
const MCP_SERVER = 'terraform-mcp-server'

function repoRoot() {
const root = process.env.GITHUB_WORKSPACE || process.cwd()
if (!fs.existsSync(path.join(root, SENTINEL_REL))) {
throw new Error(
`build-power: sentinel ${SENTINEL_REL} not found in repo root ` +
`${root}; refusing to run (wrong working directory?)`
)
}
return root
}

function splitFrontmatter(src) {
// parts[0] == '' (before first ---), [1] == frontmatter, [2] == body
const parts = src.split('---')
if (parts.length < 3 || parts[0].trim() !== '') {
throw new Error(`build-power: ${SKILL_REL} has no leading --- frontmatter`)
}
return { fm: parts[1], body: parts.slice(2).join('---') }
}

// The SKILL.md frontmatter is a fixed shape: top-level name/description/
// license, then a metadata: block with 2-space-indented author/version.
function parseFrontmatter(fm) {
const out = {}
for (const raw of fm.split('\n')) {
const line = raw.replace(/\r$/, '')
let m
if ((m = line.match(/^name:\s*(.+?)\s*$/))) out.name = m[1]
else if ((m = line.match(/^description:\s*(.+?)\s*$/)))
out.description = m[1]
else if ((m = line.match(/^\s+author:\s*(.+?)\s*$/))) out.author = m[1]
else if ((m = line.match(/^\s+version:\s*(.+?)\s*$/))) out.version = m[1]
}
for (const k of ['name', 'description', 'author', 'version']) {
if (!out[k]) {
throw new Error(`build-power: ${SKILL_REL} frontmatter missing ${k}`)
}
}
return out
}

function yamlDoubleQuoted(s) {
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
}

function buildPower(root) {
const skillSrc = fs.readFileSync(path.join(root, SKILL_REL), 'utf8')
const { fm, body } = splitFrontmatter(skillSrc)
const meta = parseFrontmatter(fm)

const codex = JSON.parse(fs.readFileSync(path.join(root, CODEX_REL), 'utf8'))
const displayName =
(codex.interface && codex.interface.displayName) || meta.name
const keywords = Array.isArray(codex.keywords) ? codex.keywords : []
if (!keywords.length) {
throw new Error(`build-power: ${CODEX_REL} has no keywords`)
}

// references/ files stay in place; rewrite links so they resolve from root.
const rewritten = body
.replace(/\]\(references\//g, '](skills/terraform-skill/references/')
.replace(/^\n+/, '')
.replace(/\s*$/, '')

// 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 the validate.yml semver check reads it directly.
const front = [
'---',
`name: ${yamlDoubleQuoted(meta.name)}`,
`displayName: ${yamlDoubleQuoted(displayName)}`,
`description: ${yamlDoubleQuoted(meta.description)}`,
`keywords: [${keywords.map((k) => yamlDoubleQuoted(k)).join(', ')}]`,
`author: ${yamlDoubleQuoted(meta.author)}`,
`version: ${meta.version}`,
'---',
].join('\n')

const banner =
'<!-- GENERATED FILE - DO NOT EDIT. Source: ' +
SKILL_REL +
'. Regenerate: node .github/release/build-power.js. ' +
'CI-owned (version sync), like .codex-plugin/plugin.json. -->'

const mcpTrailer = [
'## MCP Tools (Kiro)',
'',
'This Power optionally bundles the HashiCorp official',
'`' +
MCP_SERVER +
'` (see `mcp.json`) for read-only Terraform Registry and',
'provider/module documentation lookups. Kiro registers it under the',
'Powers section of `~/.kiro/settings/mcp.json` on install. The guidance',
'above works without it; with it, registry/schema lookups are exact',
'instead of recalled.',
'',
'The image uses the floating `latest` tag. Docker caches it on first run',
'and does not auto-update; run `docker pull ' +
'hashicorp/terraform-mcp-server:latest` to refresh, or pin a specific',
'tag in `~/.kiro/settings/mcp.json` if a new release misbehaves.',
].join('\n')

return `${front}\n\n${banner}\n\n${rewritten}\n\n${mcpTrailer}\n`
}

function main() {
const root = repoRoot()
const check = process.argv.includes('--check')
const generated = buildPower(root)
const dest = path.join(root, POWER_REL)
const current = fs.existsSync(dest) ? fs.readFileSync(dest, 'utf8') : null

if (check) {
if (current !== generated) {
console.error(
`build-power: ${POWER_REL} is out of sync with ${SKILL_REL}. ` +
`Run: node .github/release/build-power.js`
)
process.exit(1)
}
console.log(`build-power: ${POWER_REL} in sync`)
return
}

if (current === generated) {
console.log(`build-power: ${POWER_REL} already up to date`)
return
}
fs.writeFileSync(dest, generated)
console.log(`build-power: wrote ${POWER_REL}`)
}

if (require.main === module) {
try {
main()
} catch (err) {
console.error(`build-power: ${err && err.message ? err.message : err}`)
process.exit(1)
}
}

module.exports = { buildPower }
15 changes: 14 additions & 1 deletion .github/release/pre-commit.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const { buildPower } = require('./build-power')

const SKILL_REL = 'skills/terraform-skill/SKILL.md'
const MANIFEST_REL = '.codex-plugin/plugin.json'
const POWER_REL = 'POWER.md'
const SENTINEL_REL = 'version.json'

function repoRoot() {
Expand Down Expand Up @@ -86,6 +88,14 @@ function updateCodexManifest(root, version) {
return MANIFEST_REL
}

// Regenerate the Kiro POWER.md from the (already version-synced) SKILL.md so
// the release commit and tag carry an in-sync tree, same as the codex
// manifest. Must run AFTER updateSkillVersion + updateCodexManifest.
function updatePowerFile(root) {
fs.writeFileSync(path.join(root, POWER_REL), buildPower(root))
return POWER_REL
}

async function preCommit(props) {
const version = props && props.version
if (!version || typeof version !== 'string') {
Expand All @@ -105,9 +115,12 @@ async function preCommit(props) {
console.log(`pre-commit: ${MANIFEST_REL} absent; skipped`)
}

const power = updatePowerFile(root)
console.log(`pre-commit: regenerated ${power} -> ${version}`)

// The action stages only version-file + changelog. Stage ours so
// they are in the release commit and the tag.
const toStage = [skill]
const toStage = [skill, power]
if (manifest) toStage.push(manifest)
execSync(`git add ${toStage.join(' ')}`, { cwd: root, stdio: 'inherit' })
} catch (err) {
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ on:
- 'skills/**'
- '.claude-plugin/**'
- '.codex-plugin/**'
- 'POWER.md'
- 'mcp.json'
- '.github/workflows/**'
- '.github/release/**'
push:
Expand All @@ -14,6 +16,8 @@ on:
- 'skills/**'
- '.claude-plugin/**'
- '.codex-plugin/**'
- 'POWER.md'
- 'mcp.json'
- '.github/workflows/**'
- '.github/release/**'
workflow_dispatch:
Expand Down Expand Up @@ -140,6 +144,49 @@ jobs:
print(f"✅ Codex manifest version in sync ({manifest_version})")
EOF

- name: Check POWER.md (Kiro) Sync
run: |
set -e
echo "🔍 Regenerating POWER.md and checking it is committed in sync..."
node .github/release/build-power.js --check
python3 << 'EOF'
import json, re, sys
import yaml

content = open('POWER.md').read()
if not content.startswith('---'):
print("❌ ERROR: POWER.md has no frontmatter")
sys.exit(1)
fm = yaml.safe_load(content.split('---', 2)[1])

required = {'name', 'displayName', 'description', 'keywords',
'author', 'version'}
missing = required - set(fm.keys())
if missing:
print(f"❌ ERROR: POWER.md frontmatter missing {missing}")
sys.exit(1)
if not isinstance(fm['keywords'], list) or not fm['keywords']:
print("❌ ERROR: POWER.md keywords must be a non-empty list")
sys.exit(1)
if not re.match(r'^\d+\.\d+\.\d+$', str(fm['version'])):
print(f"❌ ERROR: POWER.md version not semver: {fm['version']!r}")
sys.exit(1)

mcp = json.load(open('mcp.json'))
servers = list((mcp.get('mcpServers') or {}).keys())
if not servers:
print("❌ ERROR: mcp.json has no mcpServers")
sys.exit(1)
for s in servers:
if s not in content:
print(f"❌ ERROR: mcp.json server {s!r} not referenced "
f"in POWER.md")
sys.exit(1)

print(f"✅ POWER.md in sync (v{fm['version']}, "
f"{len(fm['keywords'])} keywords, MCP: {', '.join(servers)})")
EOF

- name: Check File Size
run: |
LINES=$(wc -l < skills/terraform-skill/SKILL.md)
Expand Down
27 changes: 23 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

A **Claude Code skill** - executable documentation that Claude loads to provide Terraform/OpenTofu expertise. It encodes terraform-best-practices.com patterns into Claude's context as version-controlled AI instructions.

It also ships as a **Kiro Power**: the repo-root `POWER.md` (+ optional
`mcp.json`) is generated from `SKILL.md`, so the skill content is shared, not
forked.

## Repository Structure

```
terraform-skill/
├── skills/
│ └── terraform-skill/ # Skill autodiscovered by Claude Code plugin system
│ ├── SKILL.md # Core skill file (~299 lines)
│ ├── SKILL.md # Core skill file (~299 lines) — single source of truth
│ └── references/ # Reference files loaded on demand
│ ├── ci-cd-workflows.md
│ ├── code-intelligence-lsp.md
Expand All @@ -26,15 +30,28 @@ terraform-skill/
│ ├── security-compliance.md
│ ├── state-management.md
│ └── testing-frameworks.md
├── POWER.md # GENERATED Kiro Power (from SKILL.md) — CI-owned, never hand-edit
├── mcp.json # Optional Kiro MCP (read-only terraform-mcp-server)
├── tests/ # Baseline scenarios and rationalization tracking
│ ├── baseline-scenarios.md
│ ├── compliance-verification.md
│ └── rationalization-table.md
└── .github/workflows/
├── validate.yml # PR validation (frontmatter, size, links, lint)
└── automated-release.yml # Auto-release on master push via conventional commits
└── .github/
├── release/
│ ├── pre-commit.js # Release version-sync hook (SKILL.md/codex/POWER.md)
│ └── build-power.js # POWER.md generator (`--check` for CI)
└── workflows/
├── validate.yml # PR validation (frontmatter, POWER.md sync, size, links, lint)
└── automated-release.yml # Auto-release on master push via conventional commits
```

`POWER.md` and `.codex-plugin/plugin.json` are **generated, CI-owned
artifacts** synced from `SKILL.md` by `.github/release/build-power.js` and the
release `pre-commit.js` hook. Never hand-edit them — `validate.yml` runs
`build-power.js --check` and fails the PR if `POWER.md` drifts. Edit
`SKILL.md`/`references/` and regenerate with
`node .github/release/build-power.js`.

## Development Workflow

**This is documentation, not code.** No build, no compiled tests.
Expand Down Expand Up @@ -90,6 +107,8 @@ The release workflow automatically:
- Syncs `skills/terraform-skill/SKILL.md` YAML frontmatter
`metadata.version` (the single version source; the canonical version is the
git tag managed by the release pipeline)
- Syncs `.codex-plugin/plugin.json` and regenerates `POWER.md` from
`SKILL.md`, staging both into the release commit + tag

**Never manually edit version numbers** - the CI handles this.

Expand Down
Loading
Loading