mirror of
https://github.com/medusajs/medusa-agent-skills.git
synced 2026-09-14 20:26:51 +08:00
chore: add action to sync skills to documentation changes
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
<role>
|
||||
You are a technical maintainer of the `medusajs/medusa-agent-skills` repository,
|
||||
which publishes Claude Code plugins whose skill files restate Medusa
|
||||
documentation as instructions for a coding agent. You read documentation diffs
|
||||
and judge whether they make any published instruction wrong.
|
||||
</role>
|
||||
|
||||
<task>
|
||||
Correct every skill file in this repository that the supplied Medusa
|
||||
documentation commit has made inaccurate.
|
||||
</task>
|
||||
|
||||
<context>
|
||||
All publishable content lives under `plugins/`. There are four plugins, each
|
||||
with skills at `plugins/<plugin>/skills/<skill>/`, made up of a `SKILL.md` plus
|
||||
supporting markdown in `reference/`, `references/`, `lessons/`, `checkpoints/`,
|
||||
`architecture/`, or `troubleshooting/` subdirectories. These files are consumed
|
||||
by agents, not read by people, so a wrong instruction propagates silently into
|
||||
generated code.
|
||||
|
||||
No mapping exists between documentation pages and skill files. Deciding which
|
||||
skill files a docs change affects is your judgement, made by searching the
|
||||
content. Most documentation commits affect nothing in this repository.
|
||||
|
||||
Documentation URLs resolve to paths in the `medusajs/medusa` repository as
|
||||
follows, which is how you tell whether a `https://docs.medusajs.com/...` link in
|
||||
a skill file points at a changed docs path:
|
||||
|
||||
- `/learn/*` is `www/apps/book/app/learn/*`
|
||||
- `/resources/*` is `www/apps/resources/app/*`
|
||||
- `/ui/*` is `www/apps/ui/*`
|
||||
- `/cloud/*` is `www/apps/cloud/*`
|
||||
|
||||
A workflow runs `node scripts/validate-skills.mjs` after you finish, opens a
|
||||
pull request only if files under `plugins/` differ from HEAD, and fails the run
|
||||
if any file outside `plugins/` was created or modified.
|
||||
</context>
|
||||
|
||||
<input>
|
||||
Two files have been written to the workspace before you start.
|
||||
|
||||
`/tmp/docs-change/commit.json`:
|
||||
|
||||
- `sha` (string) - the docs commit SHA.
|
||||
- `url` (string) - the commit's GitHub URL.
|
||||
- `message` (string) - the commit subject line.
|
||||
- `file_list_source` (string) - how the file list was assembled.
|
||||
|
||||
`/tmp/docs-change/changed-files.json`:
|
||||
|
||||
- `considered` (array) - the authored docs files the commit touched. Each entry
|
||||
has `status` (`"A"` added, `"M"` modified, `"D"` deleted), `path` (relative to
|
||||
the root of `medusajs/medusa`), `previous_path` (string or null, set when the
|
||||
file was renamed), `patch` (unified diff, or null), and optionally
|
||||
`patch_omitted: true` when the diff was too large to include.
|
||||
- `ignored` (array) - paths already dropped as build output, generated
|
||||
reference material, or docs apps this repository does not track. Each entry
|
||||
has `status`, `path`, and `reason`.
|
||||
</input>
|
||||
|
||||
<steps>
|
||||
1. Read `/tmp/docs-change/commit.json` and `/tmp/docs-change/changed-files.json`
|
||||
in full, including every `patch`.
|
||||
|
||||
2. For each entry in `considered`, determine what changed in substance, using
|
||||
the rules for its `status`:
|
||||
|
||||
- `"M"`: read the patch. Act only on a renamed export, a changed function or
|
||||
method signature, a new required option, a reversed default, a removed or
|
||||
replaced API, or a corrected code example. Take no action on prose
|
||||
rewording, typo fixes, formatting, or frontmatter-only changes.
|
||||
- `"A"`: a new page usually documents something new, which the skills are not
|
||||
obliged to cover. Act only if a skill file makes a claim the new page
|
||||
contradicts, or if the new page is the new home for content a skill file
|
||||
points at.
|
||||
- `"D"`: the path no longer exists. Act on it. Any `docs.medusajs.com` link
|
||||
in a skill file that resolves to this path is now dead.
|
||||
|
||||
3. Run `Glob` on `plugins/**/*.md` to list every file you may edit.
|
||||
|
||||
4. For each substantive change found in step 2, run `Grep` across `plugins/` for
|
||||
the concrete symbols, option names, commands, and URLs the patch touched.
|
||||
Use the search results to decide which files are affected. Do not infer
|
||||
affected files from directory names.
|
||||
|
||||
5. Edit only the files the change makes wrong. When the change is a `"D"`,
|
||||
repoint each dead link at its replacement page, or delete the reference when
|
||||
there is no replacement. Do not refresh the surrounding text and leave the
|
||||
dead link in place. When an entry with status `"A"` carries a `previous_path`
|
||||
equal to the deleted path, that entry is the replacement page.
|
||||
|
||||
6. Run `node scripts/validate-skills.mjs`. If it reports a problem, fix it. If
|
||||
you cannot fix it, revert the edit that caused it.
|
||||
|
||||
7. Write `/tmp/docs-change/summary.md` in the shape given in `<output_format>`.
|
||||
</steps>
|
||||
|
||||
<constraints>
|
||||
- Edit only files whose path starts with `plugins/`. Never create or modify any
|
||||
file under `.github/`, `scripts/`, `.claude-plugin/`, or `README.md`.
|
||||
- Never write a scratch, log, notes, or output file into the repository working
|
||||
directory. No `output.txt`, no `notes.md`, no `summary.md` at the repository
|
||||
root. The single file you write outside `plugins/` is
|
||||
`/tmp/docs-change/summary.md`, at that exact absolute path.
|
||||
- Never create a new skill, delete a skill, or rename a directory.
|
||||
- Never change the `name` field in a `SKILL.md` frontmatter block. Change a
|
||||
`description` field only when the docs change makes its wording factually
|
||||
wrong.
|
||||
- Make the smallest edit that makes the file correct: change only the sentences,
|
||||
code lines, or links that the docs change falsified. Do not reorder sections,
|
||||
rewrite unaffected paragraphs, or add content the docs change did not require.
|
||||
- Match the file you are editing on three points: heading depth, code-fence
|
||||
language tags, and whether instructions are written as imperatives or as
|
||||
descriptions.
|
||||
- Use no emoji in any edited file or in the summary.
|
||||
- Use only the `Read`, `Write`, `Edit`, `Glob`, `Grep`, and `WebFetch` tools,
|
||||
the `MedusaDocs` MCP server, and the read-only bash commands you are
|
||||
permitted. Run no `git` command that stages, commits, pushes, or checks out.
|
||||
- When you need to confirm how a Medusa feature behaves beyond what the patch
|
||||
shows, query the `MedusaDocs` MCP server or fetch the page from
|
||||
https://docs.medusajs.com. Do not infer behavior from the patch alone.
|
||||
- Making no edit is a correct and common outcome. Never edit a file to
|
||||
demonstrate activity.
|
||||
</constraints>
|
||||
|
||||
<error_handling>
|
||||
- If `considered` is empty or absent: make no edits and write a summary whose
|
||||
`Files updated` section is `- none`.
|
||||
- If either input file is missing or does not parse: make no edits and write a
|
||||
summary whose `Files updated` section is `- none` and whose
|
||||
`Needs human review` section names the file that could not be read.
|
||||
- If an entry has `patch_omitted: true`: fetch the page from
|
||||
https://docs.medusajs.com to see the current content. If the fetch fails, list
|
||||
that path under `Needs human review` and make no edit for it.
|
||||
- If an entry has `patch: null` and no `patch_omitted` flag: treat the `status`
|
||||
and `path` as the only evidence. For `"D"`, that is enough to repoint links.
|
||||
For `"A"` and `"M"`, list the path under `Needs human review` instead of
|
||||
guessing at content you have not seen.
|
||||
- If you cannot determine whether a changed docs path affects any skill file:
|
||||
make no edit and list that path under `Needs human review` with the reason.
|
||||
- If two skill files state the same fact and the docs change falsifies it: edit
|
||||
both. Never leave one correct and one wrong.
|
||||
- If `node scripts/validate-skills.mjs` still reports a problem after you try to
|
||||
fix it: revert the edits that caused it and list the affected docs path under
|
||||
`Needs human review`.
|
||||
</error_handling>
|
||||
|
||||
<examples>
|
||||
<example>
|
||||
<input>
|
||||
commit.json message: "docs: split migration guide into three (#16250)"
|
||||
considered:
|
||||
- status "D", path "www/apps/book/app/learn/.../migrate-from-platforms/page.mdx"
|
||||
- status "A", path "www/apps/book/app/learn/.../migrate-from-shopify/page.mdx",
|
||||
previous_path "www/apps/book/app/learn/.../migrate-from-platforms/page.mdx"
|
||||
A skill file contains the line:
|
||||
See the [migration guide](https://docs.medusajs.com/learn/.../migrate-from-platforms).
|
||||
</input>
|
||||
<output>
|
||||
The `D` path is dead, and the `A` entry names it as `previous_path`, so it is
|
||||
the replacement. Edit the skill file to repoint the link:
|
||||
|
||||
See the [migration guide](https://docs.medusajs.com/learn/.../migrate-from-shopify).
|
||||
|
||||
Summary:
|
||||
|
||||
### Files updated
|
||||
|
||||
- `plugins/medusa-dev/skills/new-user/SKILL.md` - repointed a dead link to the split migration guide, driven by the deletion of `www/apps/book/app/learn/.../migrate-from-platforms/page.mdx`
|
||||
|
||||
### Needs human review
|
||||
|
||||
- none
|
||||
</output>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<input>
|
||||
commit.json message: "docs: clarify wording on the workflows page (#16301)"
|
||||
considered:
|
||||
- status "M", path "www/apps/book/app/learn/fundamentals/workflows/page.mdx",
|
||||
patch shows only "You can create a workflow" changed to "You may create a workflow"
|
||||
and a comma added in the following sentence.
|
||||
</input>
|
||||
<output>
|
||||
The patch changes prose wording only. No export, signature, option, default, or
|
||||
code example changed, so no skill file is now wrong. Make no edits.
|
||||
|
||||
Summary:
|
||||
|
||||
### Files updated
|
||||
|
||||
- none
|
||||
|
||||
### Needs human review
|
||||
|
||||
- none
|
||||
</output>
|
||||
</example>
|
||||
</examples>
|
||||
|
||||
<output_format>
|
||||
Write exactly one file, `/tmp/docs-change/summary.md`, containing exactly these
|
||||
two level-three headings in this order and nothing else:
|
||||
|
||||
```
|
||||
### Files updated
|
||||
|
||||
- `<repository-relative path>` - what you changed and which docs path drove it
|
||||
|
||||
### Needs human review
|
||||
|
||||
- `<docs path>` - why you could not determine the impact
|
||||
```
|
||||
|
||||
Rules for the summary:
|
||||
|
||||
- Write `- none` as the sole bullet under any heading with no entries. Never
|
||||
omit a heading.
|
||||
- Under `Files updated`, one bullet per file you edited. Each bullet names a
|
||||
path starting with `plugins/` and cites the docs path that drove the edit.
|
||||
- Under `Needs human review`, one bullet per docs path you could not judge. Each
|
||||
bullet names a path inside `medusajs/medusa` and gives the reason.
|
||||
- Add no preamble, no closing remarks, and no headings beyond these two.
|
||||
</output_format>
|
||||
|
||||
<success_criteria>
|
||||
- Every path listed under `Files updated` starts with `plugins/` and its file
|
||||
content differs from HEAD.
|
||||
- No file outside `plugins/` was created or modified, and no scratch file was
|
||||
left in the repository working directory.
|
||||
- `node scripts/validate-skills.mjs` exits 0.
|
||||
- `/tmp/docs-change/summary.md` exists and contains both headings, each with at
|
||||
least one bullet.
|
||||
- No `SKILL.md` frontmatter `name` field differs from HEAD.
|
||||
- No skill file contains a `docs.medusajs.com` link that resolves to a path
|
||||
listed with status `"D"` in the input.
|
||||
- No edited file and no line of the summary contains an emoji.
|
||||
- Every docs path in `considered` appears either as the cited driver of a bullet
|
||||
under `Files updated`, or as a bullet under `Needs human review`, or in
|
||||
neither because you judged it to affect nothing.
|
||||
</success_criteria>
|
||||
@@ -0,0 +1,360 @@
|
||||
name: Sync Medusa Docs Changes
|
||||
|
||||
# Reacts to a documentation commit merged in medusajs/medusa and, when that
|
||||
# commit affects content this repository restates, opens a pull request updating
|
||||
# the affected skill files.
|
||||
#
|
||||
# Required repository secrets (Settings -> Secrets and variables -> Actions):
|
||||
# MEDUSA_APP_ID App ID of the Medusa GitHub App
|
||||
# MEDUSA_APP_PRIVATE_KEY PEM private key of the Medusa GitHub App
|
||||
# ANTHROPIC_API_KEY Anthropic API key used by claude-code-base-action
|
||||
#
|
||||
# The Medusa GitHub App must be installed on THIS repository with
|
||||
# `contents: write` and `pull-requests: write`. Every commit and pull request
|
||||
# this workflow creates is attributed to that app, never to a personal account
|
||||
# and never to the default GITHUB_TOKEN.
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [medusa-docs-updated]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
commit_sha:
|
||||
description: "Full 40-character SHA of a medusajs/medusa docs commit to process"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# Two docs commits merged close together must not race on the same base branch,
|
||||
# and neither may be dropped -- so serialize instead of cancelling.
|
||||
concurrency:
|
||||
group: sync-medusa-docs-changes
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read # writes go through the app token, not GITHUB_TOKEN
|
||||
|
||||
env:
|
||||
DOCS_REPO: medusajs/medusa
|
||||
WORK_DIR: /tmp/docs-change
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync docs changes into skills
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Must be first: everything that writes to this repository uses this token.
|
||||
# If this step fails, the Medusa GitHub App is not installed on this
|
||||
# repository or MEDUSA_APP_ID / MEDUSA_APP_PRIVATE_KEY are wrong.
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.MEDUSA_APP_ID }}
|
||||
private-key: ${{ secrets.MEDUSA_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Resolve and validate commit SHA
|
||||
id: sha
|
||||
env:
|
||||
DISPATCH_SHA: ${{ github.event.client_payload.commit_sha }}
|
||||
INPUT_SHA: ${{ inputs.commit_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
SHA="$INPUT_SHA"
|
||||
else
|
||||
SHA="$DISPATCH_SHA"
|
||||
fi
|
||||
|
||||
if ! printf '%s' "$SHA" | grep -qE '^[0-9a-fA-F]{40}$'; then
|
||||
echo "::error::invalid or missing commit_sha in dispatch payload"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=${SHA:0:8}" >> "$GITHUB_OUTPUT"
|
||||
echo "Processing $DOCS_REPO commit $SHA"
|
||||
|
||||
# A repository_dispatch that carries no file list has nothing to act on.
|
||||
# A workflow_dispatch never carries one, and takes the file list from the
|
||||
# commit API instead, so it must not exit here.
|
||||
- name: Check the payload carries docs files
|
||||
id: payload-files
|
||||
env:
|
||||
CHANGED_FILES_JSON: ${{ toJSON(github.event.client_payload.changed_files) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
COUNT=$(printf '%s' "$CHANGED_FILES_JSON" | jq 'if type == "array" then length else 0 end')
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
echo "no docs files in payload"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.payload-files.outputs.proceed == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.payload-files.outputs.proceed == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
# The default GITHUB_TOKEN can read the public medusajs/medusa repository,
|
||||
# so the app token is deliberately not used here. Note the commits API
|
||||
# returns at most 300 files; larger commits are reported as such.
|
||||
- name: Fetch the docs commit
|
||||
if: steps.payload-files.outputs.proceed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ steps.sha.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$WORK_DIR"
|
||||
if ! gh api "repos/$DOCS_REPO/commits/$SHA?per_page=300" > "$WORK_DIR/commit-api.json" 2> "$WORK_DIR/commit-api.err"; then
|
||||
# A well-formed SHA that does not exist answers 422 ("No commit
|
||||
# found for SHA"), not 404 -- both mean the same thing here.
|
||||
if grep -qE 'HTTP 404|HTTP 422|Not Found|No commit found' "$WORK_DIR/commit-api.err"; then
|
||||
echo "::error::commit $SHA was not found in $DOCS_REPO. It may have been force-pushed away or the SHA is wrong. Refusing to proceed on partial data."
|
||||
else
|
||||
echo "::error::could not fetch commit $SHA from $DOCS_REPO"
|
||||
cat "$WORK_DIR/commit-api.err"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
TOTAL=$(jq '.files | length' "$WORK_DIR/commit-api.json")
|
||||
echo "commit API returned $TOTAL file entries"
|
||||
if [ "$TOTAL" -ge 300 ]; then
|
||||
echo "::warning::the commits API caps its file list at 300 entries; this commit may have more."
|
||||
fi
|
||||
|
||||
# Mechanical filtering only: build output, generated TSDoc references, and
|
||||
# docs apps no skill restates are dropped here. Whether the remaining
|
||||
# files actually affect this repository is Claude's call, not a hardcoded
|
||||
# path mapping's.
|
||||
- name: Normalize and filter the changed-file list
|
||||
id: resolve
|
||||
if: steps.payload-files.outputs.proceed == 'true'
|
||||
env:
|
||||
PAYLOAD_JSON: ${{ toJSON(github.event.client_payload) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/resolve-docs-commit.mjs \
|
||||
--commit-api "$WORK_DIR/commit-api.json" \
|
||||
--output-dir "$WORK_DIR"
|
||||
|
||||
- name: Report commit with no docs content
|
||||
if: steps.payload-files.outputs.proceed != 'true' || steps.resolve.outputs.has_docs_files != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f "$WORK_DIR/changed-files.json" ]; then
|
||||
echo "no authored docs files in this commit after filtering build output and generated references"
|
||||
jq -r '.ignored[] | "ignored: [\(.status)] \(.path) - \(.reason)"' "$WORK_DIR/changed-files.json"
|
||||
else
|
||||
echo "no docs files in payload"
|
||||
fi
|
||||
echo "No pull request opened."
|
||||
|
||||
- name: Run Claude Code to update affected skills
|
||||
if: steps.resolve.outputs.has_docs_files == 'true'
|
||||
uses: anthropics/claude-code-base-action@e8132bc5e637a42c27763fc757faa37e1ee43b34 # beta / v0.0.63
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
prompt_file: .github/prompts/sync-medusa-docs-changes.md
|
||||
# The prompt tells the agent to confirm Medusa behavior against the
|
||||
# docs, so give it the same MCP server the plugins ship with.
|
||||
mcp_config: '{"mcpServers":{"MedusaDocs":{"type":"http","url":"https://docs.medusajs.com/mcp"}}}'
|
||||
allowed_tools: "Read,Write,Edit,Glob,Grep,WebFetch,mcp__MedusaDocs,Bash(node scripts/validate-skills.mjs),Bash(ls:*),Bash(cat:*),Bash(find:*)"
|
||||
max_turns: 60
|
||||
timeout_minutes: 30
|
||||
|
||||
# The action and the model both tend to drop scratch files into the
|
||||
# working tree -- `output.txt` most often. They are not skill content, so
|
||||
# they would trip the "modified outside plugins/" check below and fail an
|
||||
# otherwise good run. Delete them before anything looks at the diff.
|
||||
# Only untracked files are removed, so a real repository file with one of
|
||||
# these names would be left alone and still fail the check.
|
||||
- name: Remove Claude scratch artifacts
|
||||
if: steps.resolve.outputs.has_docs_files == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# If the summary landed at the repo root instead of the work dir,
|
||||
# rescue it rather than throwing away the PR body.
|
||||
if [ -f summary.md ] && ! git ls-files --error-unmatch summary.md >/dev/null 2>&1; then
|
||||
[ -f "$WORK_DIR/summary.md" ] || mv summary.md "$WORK_DIR/summary.md"
|
||||
rm -f summary.md
|
||||
echo "Moved a stray summary.md into $WORK_DIR"
|
||||
fi
|
||||
|
||||
for artifact in output.txt output.json claude-execution-output.json execution-output.json; do
|
||||
[ -e "$artifact" ] || continue
|
||||
if git ls-files --error-unmatch "$artifact" >/dev/null 2>&1; then
|
||||
echo "::warning::$artifact is tracked in this repository and was left in place"
|
||||
continue
|
||||
fi
|
||||
rm -rf "$artifact"
|
||||
echo "Removed scratch artifact $artifact"
|
||||
done
|
||||
|
||||
# There is no lint/build/test command in this repository (no package.json),
|
||||
# so this integrity check stands in for one. It fails the run rather than
|
||||
# opening a pull request with malformed skill content.
|
||||
- name: Validate skill content
|
||||
if: steps.resolve.outputs.has_docs_files == 'true'
|
||||
run: node scripts/validate-skills.mjs
|
||||
|
||||
- name: Detect edits on disk
|
||||
id: diff
|
||||
if: steps.resolve.outputs.has_docs_files == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Check for strays first. A run that edited nothing but left a file
|
||||
# outside plugins/ behind is still a prompt violation and must fail
|
||||
# loudly rather than pass as "nothing to do".
|
||||
# cut -c4- keeps paths containing spaces intact.
|
||||
STRAY=$(git status --porcelain -- . ':(exclude)plugins' | cut -c4-)
|
||||
if [ -n "$STRAY" ]; then
|
||||
echo "::error::files outside plugins/ were created or modified, which the prompt forbids:"
|
||||
echo "$STRAY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHANGED=$(git status --porcelain -- plugins | cut -c4-)
|
||||
if [ -z "$CHANGED" ]; then
|
||||
echo "No skill files changed on disk. No pull request opened."
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "$CHANGED" > "$WORK_DIR/changed-plugin-files.txt"
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
# Runs after the plugins-only check above, so Claude is still forbidden
|
||||
# from touching .claude-plugin/ while the workflow itself is free to.
|
||||
# An edited skill that ships under an unchanged version is not picked up
|
||||
# by installs, so every content change moves a version.
|
||||
- name: Bump plugin and marketplace versions
|
||||
id: bump
|
||||
if: steps.resolve.outputs.has_docs_files == 'true' && steps.diff.outputs.has_changes == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/bump-versions.mjs --output "$WORK_DIR/version-bumps.md" \
|
||||
< "$WORK_DIR/changed-plugin-files.txt"
|
||||
|
||||
- name: Build pull request body
|
||||
id: pr-body
|
||||
if: steps.resolve.outputs.has_docs_files == 'true' && steps.diff.outputs.has_changes == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "## Automated skill update from a Medusa docs change"
|
||||
echo
|
||||
echo "Source commit: $(jq -r '.url' "$WORK_DIR/commit.json")"
|
||||
echo
|
||||
echo "> $(jq -r '.message' "$WORK_DIR/commit.json")"
|
||||
echo
|
||||
echo "File list source: $(jq -r '.file_list_source' "$WORK_DIR/commit.json")"
|
||||
echo
|
||||
echo "### Docs files Claude reviewed"
|
||||
echo
|
||||
jq -r '.considered[] | "- `\(.path)` (\(.status))" + (if .patch_omitted then " - diff omitted, over the patch budget" else "" end)' "$WORK_DIR/changed-files.json"
|
||||
echo
|
||||
# Claude reports what it changed and what it could not judge. The
|
||||
# fallback keeps the body honest if it never wrote a summary.
|
||||
if [ -f "$WORK_DIR/summary.md" ]; then
|
||||
cat "$WORK_DIR/summary.md"
|
||||
else
|
||||
echo "### Files updated"
|
||||
echo
|
||||
git status --porcelain -- plugins | cut -c4- | sed 's/^/- `/; s/$/`/'
|
||||
echo
|
||||
echo "Claude did not write a summary, so this list is derived from the diff."
|
||||
fi
|
||||
echo
|
||||
if [ -s "$WORK_DIR/version-bumps.md" ]; then
|
||||
cat "$WORK_DIR/version-bumps.md"
|
||||
fi
|
||||
echo "---"
|
||||
echo
|
||||
echo "Generated by the \`sync-medusa-docs-changes\` workflow. Review before merging: the edits are model-generated and may have missed context."
|
||||
} > "$WORK_DIR/pr-body.md"
|
||||
cat "$WORK_DIR/pr-body.md"
|
||||
|
||||
- name: Commit and push to a dedicated branch
|
||||
id: push
|
||||
if: steps.resolve.outputs.has_docs_files == 'true' && steps.diff.outputs.has_changes == 'true'
|
||||
env:
|
||||
SHORT_SHA: ${{ steps.sha.outputs.short_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BRANCH="docs-sync/medusa-$SHORT_SHA"
|
||||
git config user.name "medusa-bot[bot]"
|
||||
git config user.email "medusa-bot[bot]@users.noreply.github.com"
|
||||
# Deterministic branch name so a re-run of the same docs commit updates
|
||||
# the existing pull request instead of opening a second one.
|
||||
git checkout -B "$BRANCH"
|
||||
# .claude-plugin holds the marketplace manifest, written by the bump
|
||||
# step above -- never by Claude.
|
||||
git add -- plugins .claude-plugin
|
||||
git commit -m "chore(skills): sync with medusa docs commit $SHORT_SHA"
|
||||
# Never force-push: a plain push to a branch we just branched from the
|
||||
# default branch either fast-forwards or fails loudly.
|
||||
git push origin "$BRANCH"
|
||||
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update the pull request
|
||||
if: steps.resolve.outputs.has_docs_files == 'true' && steps.diff.outputs.has_changes == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
BRANCH: ${{ steps.push.outputs.branch }}
|
||||
SHORT_SHA: ${{ steps.sha.outputs.short_sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="chore(skills): sync with Medusa docs commit $SHORT_SHA"
|
||||
|
||||
EXISTING=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number' || true)
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh pr edit "$EXISTING" --repo "$REPO" \
|
||||
--title "$TITLE" \
|
||||
--body-file "$WORK_DIR/pr-body.md"
|
||||
echo "Updated existing pull request #$EXISTING"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! gh pr create --repo "$REPO" \
|
||||
--base "${{ github.event.repository.default_branch }}" \
|
||||
--head "$BRANCH" \
|
||||
--title "$TITLE" \
|
||||
--body-file "$WORK_DIR/pr-body.md" 2> "$WORK_DIR/pr-create.err"; then
|
||||
if grep -qE 'HTTP 40[34]|Resource not accessible' "$WORK_DIR/pr-create.err"; then
|
||||
echo "::error::could not open the pull request with the Medusa GitHub App token. The app is either not installed on $REPO or lacks contents:write and pull-requests:write. Not falling back to any other credential."
|
||||
fi
|
||||
cat "$WORK_DIR/pr-create.err"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The Claude step and the validation step both run before anything is
|
||||
# pushed, so a failure there leaves no branch behind. This only cleans up
|
||||
# if the push succeeded and a later step failed.
|
||||
- name: Clean up branch on failure
|
||||
if: failure() && steps.push.outputs.branch != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
BRANCH: ${{ steps.push.outputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
OPEN=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number' || true)
|
||||
if [ -z "$OPEN" ]; then
|
||||
gh api --method DELETE "repos/$REPO/git/refs/heads/$BRANCH" || true
|
||||
echo "Deleted partial branch $BRANCH"
|
||||
fi
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bumps the patch version of every plugin whose content changed, plus the
|
||||
* marketplace version, so a published skill edit is actually picked up by
|
||||
* installs.
|
||||
*
|
||||
* Input (stdin): the changed paths, one per line, as produced by
|
||||
* `git status --porcelain -- plugins | cut -c4-`.
|
||||
* Output (--output <file>, optional): a markdown list of the bumps, for the
|
||||
* pull request body.
|
||||
*
|
||||
* Only `plugins/<name>/...` paths count. A run where no plugin content changed
|
||||
* bumps nothing, including the marketplace.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs"
|
||||
|
||||
const MARKETPLACE = ".claude-plugin/marketplace.json"
|
||||
|
||||
function arg(name) {
|
||||
const i = process.argv.indexOf(name)
|
||||
return i === -1 ? null : process.argv[i + 1]
|
||||
}
|
||||
|
||||
/** Bumps the patch component of a strict X.Y.Z version. */
|
||||
function bumpPatch(version, label) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version || "").trim())
|
||||
if (!match) {
|
||||
console.error(
|
||||
`::error::${label} has version "${version}", which is not a plain X.Y.Z. Refusing to guess how to bump it.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const [, major, minor, patch] = match
|
||||
return `${major}.${minor}.${Number(patch) + 1}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a single `"version": "..."` value in place, leaving the rest of the
|
||||
* file byte-for-byte alone. Reserializing with JSON.stringify would reformat
|
||||
* files that are hand-maintained.
|
||||
*/
|
||||
function writeVersion(path, from, to) {
|
||||
const original = readFileSync(path, "utf8")
|
||||
const pattern = new RegExp(`("version"\\s*:\\s*)"${from.replace(/\./g, "\\.")}"`)
|
||||
if (!pattern.test(original)) {
|
||||
console.error(`::error::could not locate the version string in ${path}`)
|
||||
process.exit(1)
|
||||
}
|
||||
writeFileSync(path, original.replace(pattern, `$1"${to}"`))
|
||||
}
|
||||
|
||||
function main() {
|
||||
const outputPath = arg("--output")
|
||||
|
||||
const changed = readFileSync(0, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
// plugins/<name>/... -- anything else is not publishable plugin content.
|
||||
const affected = new Set()
|
||||
for (const path of changed) {
|
||||
const match = /^plugins\/([^/]+)\//.exec(path)
|
||||
if (match) affected.add(match[1])
|
||||
}
|
||||
|
||||
if (affected.size === 0) {
|
||||
console.error("no plugin content changed, nothing to bump")
|
||||
if (outputPath) writeFileSync(outputPath, "")
|
||||
return
|
||||
}
|
||||
|
||||
const bumps = []
|
||||
|
||||
for (const plugin of [...affected].sort()) {
|
||||
const manifest = `plugins/${plugin}/.claude-plugin/plugin.json`
|
||||
if (!existsSync(manifest)) {
|
||||
console.error(`::error::${plugin} has changed content but no ${manifest}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const current = JSON.parse(readFileSync(manifest, "utf8")).version
|
||||
const next = bumpPatch(current, manifest)
|
||||
writeVersion(manifest, current, next)
|
||||
bumps.push({ name: plugin, path: manifest, from: current, to: next })
|
||||
}
|
||||
|
||||
// The marketplace version gates the whole catalogue, so it moves whenever any
|
||||
// plugin in it does.
|
||||
if (!existsSync(MARKETPLACE)) {
|
||||
console.error(`::error::${MARKETPLACE} is missing`)
|
||||
process.exit(1)
|
||||
}
|
||||
const marketplaceCurrent = JSON.parse(readFileSync(MARKETPLACE, "utf8"))
|
||||
.metadata.version
|
||||
const marketplaceNext = bumpPatch(marketplaceCurrent, MARKETPLACE)
|
||||
writeVersion(MARKETPLACE, marketplaceCurrent, marketplaceNext)
|
||||
bumps.push({
|
||||
name: "marketplace",
|
||||
path: MARKETPLACE,
|
||||
from: marketplaceCurrent,
|
||||
to: marketplaceNext,
|
||||
})
|
||||
|
||||
for (const b of bumps) {
|
||||
console.error(`bumped ${b.name}: ${b.from} -> ${b.to}`)
|
||||
}
|
||||
|
||||
if (outputPath) {
|
||||
const lines = [
|
||||
"### Versions bumped",
|
||||
"",
|
||||
...bumps.map((b) => `- \`${b.path}\` - ${b.from} to ${b.to}`),
|
||||
"",
|
||||
]
|
||||
writeFileSync(outputPath, lines.join("\n"))
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`bumped=${bumps.map((b) => `${b.name}@${b.to}`).join(" ")}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Normalizes a `medusa-docs-updated` dispatch payload and the GitHub commit API
|
||||
* response into a single changed-files list, attaches each file's patch, and
|
||||
* drops paths that are not authored documentation.
|
||||
*
|
||||
* This does NOT decide whether the commit affects this repository -- that
|
||||
* judgement is Claude's. The filtering here is only mechanical: build output,
|
||||
* generated reference material, and docs apps whose content no skill restates.
|
||||
*
|
||||
* Env:
|
||||
* EVENT_NAME "repository_dispatch" | "workflow_dispatch"
|
||||
* PAYLOAD_JSON the raw github.event.client_payload as JSON ("" or "null" when absent)
|
||||
*
|
||||
* Args:
|
||||
* --commit-api <file> JSON body of GET /repos/medusajs/medusa/commits/<sha>
|
||||
* --output-dir <dir> where commit.json and changed-files.json are written
|
||||
*
|
||||
* The sender caps `changed_files` at 100 entries with no truncation flag, so a
|
||||
* payload of exactly 100 is treated as truncated and the API response wins.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const PAYLOAD_CAP = 100
|
||||
|
||||
/** Total bytes of unified diff handed to Claude. */
|
||||
const PATCH_BUDGET_BYTES = 250_000
|
||||
|
||||
/** Docs apps whose content no skill in this repository restates. */
|
||||
const IGNORED_APPS = [
|
||||
["www/apps/docs/", "landing and redirect app, no prose"],
|
||||
["www/apps/bloom/", "Bloom framework, not covered by any skill"],
|
||||
["www/apps/user-guide/", "merchant-facing admin guide, not developer content"],
|
||||
["www/apps/api-reference/", "OAS-generated endpoint reference"],
|
||||
]
|
||||
|
||||
/**
|
||||
* Generated or tooling paths. `www/apps/resources/references/` is the generated
|
||||
* TSDoc source and `www/apps/resources/app/references/` is where it is mounted
|
||||
* for the site -- neither is authored prose, and both churn on every release.
|
||||
*/
|
||||
const IGNORED_PATTERNS = [
|
||||
[/^www\/apps\/resources\/references\//, "generated TSDoc reference source"],
|
||||
[/^www\/apps\/resources\/app\/references\//, "generated TSDoc reference pages"],
|
||||
[/\/generated\//, "build output"],
|
||||
[/\/sidebar\.mjs$/, "generated sidebar"],
|
||||
[/\/sidebars\//, "generated sidebar data"],
|
||||
[/\/public\//, "static asset"],
|
||||
[/^www\/(utils|packages)\//, "docs tooling, not documentation"],
|
||||
]
|
||||
|
||||
const CONTENT_EXTENSIONS = [".mdx", ".md"]
|
||||
|
||||
function arg(name) {
|
||||
const i = process.argv.indexOf(name)
|
||||
return i === -1 ? null : process.argv[i + 1]
|
||||
}
|
||||
|
||||
function normalizeApiStatus(status) {
|
||||
switch (status) {
|
||||
case "added":
|
||||
case "copied":
|
||||
return "A"
|
||||
case "removed":
|
||||
return "D"
|
||||
default:
|
||||
return "M"
|
||||
}
|
||||
}
|
||||
|
||||
/** Flattens the API's file list, expanding renames into a D + an A. */
|
||||
function filesFromApi(commit) {
|
||||
const out = []
|
||||
for (const file of commit.files || []) {
|
||||
if (file.status === "renamed") {
|
||||
if (file.previous_filename) {
|
||||
out.push({ status: "D", path: file.previous_filename, patch: null })
|
||||
}
|
||||
out.push({
|
||||
status: "A",
|
||||
path: file.filename,
|
||||
previous_path: file.previous_filename || null,
|
||||
patch: file.patch || null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
out.push({
|
||||
status: normalizeApiStatus(file.status),
|
||||
path: file.filename,
|
||||
patch: file.patch || null,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Returns a reason string when the path is not authored documentation. */
|
||||
function ignoreReason(path) {
|
||||
if (!path.startsWith("www/apps/")) return "outside www/apps"
|
||||
for (const [prefix, reason] of IGNORED_APPS) {
|
||||
if (path.startsWith(prefix)) return reason
|
||||
}
|
||||
for (const [re, reason] of IGNORED_PATTERNS) {
|
||||
if (re.test(path)) return reason
|
||||
}
|
||||
if (!CONTENT_EXTENSIONS.some((ext) => path.endsWith(ext))) {
|
||||
return "not an authored content file"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function main() {
|
||||
const commitApiPath = arg("--commit-api")
|
||||
const outputDir = arg("--output-dir")
|
||||
if (!commitApiPath || !outputDir) {
|
||||
console.error(
|
||||
"usage: resolve-docs-commit.mjs --commit-api <file> --output-dir <dir>"
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
const commit = JSON.parse(readFileSync(commitApiPath, "utf8"))
|
||||
const apiFiles = filesFromApi(commit)
|
||||
|
||||
let payload = null
|
||||
const rawPayload = (process.env.PAYLOAD_JSON || "").trim()
|
||||
if (rawPayload && rawPayload !== "null") {
|
||||
try {
|
||||
payload = JSON.parse(rawPayload)
|
||||
} catch (err) {
|
||||
console.error(`could not parse client_payload: ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const payloadFiles = Array.isArray(payload?.changed_files)
|
||||
? payload.changed_files
|
||||
: null
|
||||
|
||||
let files
|
||||
let source
|
||||
if (!payloadFiles || payloadFiles.length === 0) {
|
||||
// workflow_dispatch carries no payload; a dispatch with no files at all is
|
||||
// handled by the caller before we get here.
|
||||
files = apiFiles
|
||||
source = "api (no changed_files in payload)"
|
||||
} else if (payloadFiles.length >= PAYLOAD_CAP) {
|
||||
files = apiFiles
|
||||
source = `api (payload hit the ${PAYLOAD_CAP}-entry cap and is assumed truncated)`
|
||||
} else {
|
||||
// Trust the payload's list, but take patches and rename info from the API.
|
||||
const byPath = new Map(apiFiles.map((f) => [f.path, f]))
|
||||
files = payloadFiles.map((f) => {
|
||||
const apiEntry = byPath.get(f.path)
|
||||
return {
|
||||
status: String(f.status || "M").toUpperCase().slice(0, 1),
|
||||
path: f.path,
|
||||
previous_path: apiEntry?.previous_path || null,
|
||||
patch: apiEntry?.patch || null,
|
||||
}
|
||||
})
|
||||
source = "payload (enriched with patches from the commit API)"
|
||||
}
|
||||
|
||||
const considered = []
|
||||
const ignored = []
|
||||
for (const file of files) {
|
||||
if (!file.path) continue
|
||||
const reason = ignoreReason(file.path)
|
||||
if (reason) {
|
||||
ignored.push({ status: file.status, path: file.path, reason })
|
||||
} else {
|
||||
considered.push({
|
||||
status: file.status,
|
||||
path: file.path,
|
||||
previous_path: file.previous_path || null,
|
||||
patch: file.patch || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the prompt bounded. Patches beyond the budget are dropped, but the
|
||||
// file stays in the list with patch_omitted set, so the omission is visible
|
||||
// rather than silent -- Claude can read the page itself if it needs detail.
|
||||
let budget = PATCH_BUDGET_BYTES
|
||||
let patchesOmitted = 0
|
||||
for (const file of considered) {
|
||||
const size = file.patch ? file.patch.length : 0
|
||||
if (size === 0) continue
|
||||
if (size <= budget) {
|
||||
budget -= size
|
||||
} else {
|
||||
file.patch = null
|
||||
file.patch_omitted = true
|
||||
patchesOmitted++
|
||||
}
|
||||
}
|
||||
|
||||
const commitInfo = {
|
||||
sha: commit.sha,
|
||||
url: commit.html_url,
|
||||
message: (payload?.commit_message || commit.commit?.message || "")
|
||||
.split("\n")[0]
|
||||
.trim(),
|
||||
repository: payload?.repository || "medusajs/medusa",
|
||||
file_list_source: source,
|
||||
counts: {
|
||||
considered: considered.length,
|
||||
ignored: ignored.length,
|
||||
patches_omitted: patchesOmitted,
|
||||
},
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(outputDir, "commit.json"),
|
||||
JSON.stringify(commitInfo, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(outputDir, "changed-files.json"),
|
||||
JSON.stringify({ considered, ignored }, null, 2)
|
||||
)
|
||||
|
||||
console.error(`file list source: ${source}`)
|
||||
console.error(
|
||||
`${considered.length} docs file(s) to consider, ${ignored.length} ignored`
|
||||
)
|
||||
for (const f of considered) {
|
||||
console.error(` [${f.status}] ${f.path}`)
|
||||
}
|
||||
if (patchesOmitted > 0) {
|
||||
console.error(
|
||||
`::warning::patch budget of ${PATCH_BUDGET_BYTES} bytes exhausted: ${patchesOmitted} of ${considered.length} patches omitted`
|
||||
)
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`has_docs_files=${considered.length > 0}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Integrity checks for the plugin/skill content in this repository.
|
||||
*
|
||||
* This repository has no package.json, so there is no lint/build/test command
|
||||
* to run. These checks stand in for one: they catch the ways an automated edit
|
||||
* can break a skill (malformed frontmatter, renamed skill, dead relative link,
|
||||
* invalid plugin manifest) before a pull request is opened.
|
||||
*
|
||||
* Usage: node scripts/validate-skills.mjs
|
||||
* Exits non-zero and prints one line per problem when anything fails.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"
|
||||
import { join, dirname, resolve, relative } from "node:path"
|
||||
|
||||
const ROOT = resolve(new URL("..", import.meta.url).pathname)
|
||||
const problems = []
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === "node_modules" || entry === ".git") continue
|
||||
const full = join(dir, entry)
|
||||
if (statSync(full).isDirectory()) walk(full, out)
|
||||
else out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const allFiles = walk(ROOT)
|
||||
const rel = (p) => relative(ROOT, p)
|
||||
|
||||
// 1. Every JSON file must parse.
|
||||
for (const file of allFiles.filter((f) => f.endsWith(".json"))) {
|
||||
try {
|
||||
JSON.parse(readFileSync(file, "utf8"))
|
||||
} catch (err) {
|
||||
problems.push(`${rel(file)}: invalid JSON -- ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Every plugin listed in the marketplace must exist and have a manifest.
|
||||
const marketplacePath = join(ROOT, ".claude-plugin", "marketplace.json")
|
||||
if (existsSync(marketplacePath)) {
|
||||
try {
|
||||
const marketplace = JSON.parse(readFileSync(marketplacePath, "utf8"))
|
||||
for (const plugin of marketplace.plugins || []) {
|
||||
const source = join(ROOT, plugin.source)
|
||||
if (!existsSync(source)) {
|
||||
problems.push(
|
||||
`.claude-plugin/marketplace.json: plugin source does not exist -- ${plugin.source}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const manifest = join(source, ".claude-plugin", "plugin.json")
|
||||
if (!existsSync(manifest)) {
|
||||
problems.push(`${plugin.source}: missing .claude-plugin/plugin.json`)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// already reported by the JSON check above
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Every SKILL.md must have frontmatter with a slug `name` and a
|
||||
// `description`, and no two skills may share a name (Claude Code resolves
|
||||
// skills by the frontmatter name, not by directory).
|
||||
const seenNames = new Map()
|
||||
const skillFiles = allFiles.filter((f) => f.endsWith("/SKILL.md"))
|
||||
if (skillFiles.length === 0) {
|
||||
problems.push("no SKILL.md files found -- did the edit remove them all?")
|
||||
}
|
||||
for (const file of skillFiles) {
|
||||
const content = readFileSync(file, "utf8")
|
||||
if (!content.startsWith("---\n")) {
|
||||
problems.push(`${rel(file)}: missing YAML frontmatter`)
|
||||
continue
|
||||
}
|
||||
const end = content.indexOf("\n---", 4)
|
||||
if (end === -1) {
|
||||
problems.push(`${rel(file)}: frontmatter is not terminated by ---`)
|
||||
continue
|
||||
}
|
||||
const frontmatter = content.slice(4, end)
|
||||
const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim()
|
||||
const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim()
|
||||
|
||||
if (!name) problems.push(`${rel(file)}: frontmatter has no "name"`)
|
||||
if (!description)
|
||||
problems.push(`${rel(file)}: frontmatter has no "description"`)
|
||||
|
||||
if (name && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) {
|
||||
problems.push(
|
||||
`${rel(file)}: frontmatter name "${name}" is not a lowercase kebab-case slug`
|
||||
)
|
||||
}
|
||||
if (name && seenNames.has(name)) {
|
||||
problems.push(
|
||||
`${rel(file)}: duplicate skill name "${name}", also used by ${seenNames.get(name)}`
|
||||
)
|
||||
} else if (name) {
|
||||
seenNames.set(name, rel(file))
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Relative markdown links between skill files must resolve.
|
||||
const LINK_RE = /\[[^\]]*\]\(([^)\s]+)\)/g
|
||||
for (const file of allFiles.filter((f) => f.endsWith(".md"))) {
|
||||
const content = readFileSync(file, "utf8")
|
||||
for (const match of content.matchAll(LINK_RE)) {
|
||||
const target = match[1]
|
||||
if (/^(https?:|mailto:|#)/.test(target)) continue
|
||||
const path = target.split("#")[0]
|
||||
if (!path) continue
|
||||
if (!existsSync(resolve(dirname(file), path))) {
|
||||
problems.push(`${rel(file)}: broken relative link -- ${target}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error(`validate-skills: ${problems.length} problem(s) found`)
|
||||
for (const p of problems) console.error(` ${p}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`validate-skills: OK (${skillFiles.length} skills, ${allFiles.filter((f) => f.endsWith(".md")).length} markdown files)`
|
||||
)
|
||||
Reference in New Issue
Block a user