diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..66dce83 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Normalize line endings +* text=auto +*.ts eol=lf +*.js eol=lf +*.json eol=lf +*.md eol=lf +*.yml eol=lf +*.yaml eol=lf diff --git a/.github/workflows/label-sync.yml b/.github/workflows/label-sync.yml index a84dfa9..ebf3a5c 100644 --- a/.github/workflows/label-sync.yml +++ b/.github/workflows/label-sync.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Get changed files id: changed diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c081a4b..8c5739e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -4,11 +4,7 @@ on: push: branches: [main] paths: - - "skills/**" - - "rules/**" - - "mcp-tools.json" - - "site.json" - - ".cursor-plugin/plugin.json" + - "docs/**" - "assets/**" workflow_dispatch: @@ -21,34 +17,17 @@ concurrency: cancel-in-progress: true jobs: - build-and-deploy: - + deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Checkout site template - uses: actions/checkout@v4 - with: - repository: TMHSDigital/Developer-Tools-Directory - sparse-checkout: site-template - path: _template - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - run: pip install Jinja2 - - - name: Build site - run: python _template/site-template/build_site.py --repo-root . --out docs + - uses: actions/checkout@v6 - - uses: actions/configure-pages@v5 + - uses: actions/configure-pages@v6 - - uses: actions/upload-pages-artifact@v4 + - uses: actions/upload-pages-artifact@v5 with: path: docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4568819..4146a90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,8 +13,8 @@ concurrency: cancel-in-progress: false jobs: - version-and-release: - name: Bump version, tag, and release + tag-and-release: + name: Tag and release runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: @@ -24,131 +24,39 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Get current version - id: current + - name: Read version from package.json + id: ver run: | version=$(python3 -c "import json; print(json.load(open('package.json'))['version'])") - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "Current version: $version" - - - name: Determine bump type from commits - id: bump - run: | - last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -z "$last_tag" ]; then - commits=$(git log --oneline --format="%s") - else - commits=$(git log "$last_tag"..HEAD --oneline --format="%s") - fi - - echo "Commits since last release:" - echo "$commits" - - bump="patch" - if echo "$commits" | grep -qiE "^(feat|feature)(\(.+\))?!:|BREAKING CHANGE"; then - bump="major" - elif echo "$commits" | grep -qiE "^(feat|feature)(\(.+\))?:"; then - bump="minor" - fi - - echo "bump=$bump" >> "$GITHUB_OUTPUT" - echo "Bump type: $bump" - - - name: Compute new version - id: new - run: | - current="${{ steps.current.outputs.version }}" - bump="${{ steps.bump.outputs.bump }}" - - IFS='.' read -r major minor patch <<< "$current" - - # Initial release: when there is no prior tag, hold version at the - # value already in the manifest. The first release is cut at the - # scaffolded version (typically 0.1.0) instead of bumping to 0.2.0 - # on the first feat: commit. After the first tag exists, normal - # conventional-commit-driven bumping resumes. - last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -z "$last_tag" ]; then - new_version="$current" - else - case "$bump" in - major) major=$((major + 1)); minor=0; patch=0 ;; - minor) minor=$((minor + 1)); patch=0 ;; - patch) patch=$((patch + 1)) ;; - esac - new_version="$major.$minor.$patch" - fi - - echo "version=$new_version" >> "$GITHUB_OUTPUT" - echo "New version: $new_version" + echo "Version from package.json: $version" - name: Check if tag already exists id: check run: | - new_version="${{ steps.new.outputs.version }}" - if git rev-parse "v$new_version" >/dev/null 2>&1; then + version="${{ steps.ver.outputs.version }}" + if git rev-parse "v$version" >/dev/null 2>&1; then echo "skip=true" >> "$GITHUB_OUTPUT" - echo "Tag v$new_version already exists, skipping" + echo "Tag v$version already exists, skipping release" else echo "skip=false" >> "$GITHUB_OUTPUT" + echo "Tag v$version does not exist, proceeding" fi - - name: Update version files + - name: Create and push tags if: steps.check.outputs.skip == 'false' - env: - NEW_VERSION: ${{ steps.new.outputs.version }} - OLD_VERSION: ${{ steps.current.outputs.version }} run: | - python3 -c " - import json, os - - new_version = os.environ['NEW_VERSION'] - old_version = os.environ['OLD_VERSION'] - - readme = 'README.md' - if os.path.exists(readme): - with open(readme) as f: - content = f.read() - content = content.replace( - f'version-{old_version}-blue', - f'version-{new_version}-blue' - ) - with open(readme, 'w') as f: - f.write(content) - " + version="${{ steps.ver.outputs.version }}" + IFS='.' read -r major minor _patch <<< "$version" - - name: Sync release docs - if: steps.check.outputs.skip == 'false' - uses: TMHSDigital/Developer-Tools-Directory/.github/actions/release-doc-sync@v1 - with: - plugin-version: ${{ steps.new.outputs.version }} - previous-version: ${{ steps.current.outputs.version }} - - - name: Commit version bump - if: steps.check.outputs.skip == 'false' - run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - if git diff --cached --quiet; then - echo "No changes to commit" - else - git commit -m "chore: bump version to ${{ steps.new.outputs.version }} [skip ci]" - git push origin main - fi - - - name: Create and push tags - if: steps.check.outputs.skip == 'false' - run: | - new_version="${{ steps.new.outputs.version }}" - IFS='.' read -r major minor _patch <<< "$new_version" - git tag "v$new_version" + git tag "v$version" git tag -f "v$major" git tag -f "v$major.$minor" - git push origin "v$new_version" + git push origin "v$version" git push origin "v$major" --force git push origin "v$major.$minor" --force @@ -157,6 +65,6 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create "v${{ steps.new.outputs.version }}" \ - --title "v${{ steps.new.outputs.version }}" \ + gh release create "v${{ steps.ver.outputs.version }}" \ + --title "v${{ steps.ver.outputs.version }}" \ --generate-notes diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 2c0d62b..fc95102 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: stale-issue-message: "This issue has been automatically marked as stale due to inactivity. It will be closed in 7 days if no further activity occurs." stale-pr-message: "This PR has been automatically marked as stale due to inactivity. It will be closed in 7 days if no further activity occurs." diff --git a/CHANGELOG.md b/CHANGELOG.md index 971451f..1eec248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to Developer Tools MCP will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +## [0.2.0] - 2026-05-24 + +### Added + +- `devtools_restampRepo`: preview or apply a standards-version restamp across fleet repos. Dry-run calls the canonical drift checker (`scripts/drift_check/cli.py`) to discover drifted files. Apply stamps files via Phase 1 Python scripts, opens per-repo branches and PRs, and squash-merges when CI passes. Requires `DEVTOOLS_META_ROOT` and `GH_TOKEN`. +- `devtools_syncRegistry`: preview or apply `registry.json` field edits and regenerate derived artifacts (README.md, CLAUDE.md, docs/index.html). Update-only boundary: rejects slugs not already in the registry. Dry-run runs `sync_from_registry.py --check` against the edited registry without committing. Apply writes edits, regenerates, verifies with `--check`, and opens a meta-repo PR that is squash-merged when CI passes. Requires `DEVTOOLS_META_ROOT` and `GH_TOKEN`. +- `devtools_createTool`: plan or execute creation of a new ecosystem tool repo. Dry-run validates inputs, runs `scaffold/create-tool.py` to a temp dir, lists generated files, reports the would-be registry entry and `STANDARDS_VERSION` at birth. Apply creates a real public GitHub repo (guarded by `confirm=true` and token with repo-creation scope), scaffolds, bootstraps, applies branch protection matching the type, and registers via meta-repo PR. Requires `DEVTOOLS_META_ROOT`. +- `.gitattributes` with `text=auto` and `*.ts eol=lf` to eliminate CRLF phantom changes in git status. +- GitHub Pages documentation site at `docs/index.html` covering all 7 tools, quick start, and environment variable reference. + ## [0.1.0] - 2026-05-24 ### Added diff --git a/README.md b/README.md index 0370fb7..82e4d6d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # Developer Tools MCP -MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable read tools. +MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable tools. ![License: CC-BY-NC-ND-4.0](https://img.shields.io/badge/license-CC--BY--NC--ND--4.0-green) -![Version](https://img.shields.io/badge/version-0.1.0-blue) +![Version](https://img.shields.io/badge/version-0.2.0-blue) -**v1 is read-only.** No write operations, no secrets, no tokens committed. The write roadmap is documented in [ROADMAP.md](ROADMAP.md). +**v0.2.0 adds a write surface.** Three write tools ship alongside the four read tools. All write tools default to dry-run and require `DEVTOOLS_META_ROOT` and `GH_TOKEN`. The write surface is now complete; see [ROADMAP.md](ROADMAP.md). --- ## What it does -This server gives any MCP-capable agent four read tools against the ecosystem: +### Read tools (no token needed for public repos) | Tool | Description | |------|-------------| @@ -20,7 +20,19 @@ This server gives any MCP-capable agent four read tools against the ecosystem: | `devtools_checkDrift` | Return drift findings: standards-version mismatches and missing required workflows | | `devtools_inspectRepo` | Detailed view of one repo: GitHub metadata, open PRs, CI run status, standards-version | -`devtools_checkDrift` fetches `standards/drift-checker.config.json` from the meta-repo at runtime and applies its thresholds and required-workflow lists. The canonical drift checker (`scripts/drift_check/cli.py` in the meta-repo) is authoritative; this tool is a convenience reader for agents that cannot run Python locally. +`devtools_checkDrift` fetches `standards/drift-checker.config.json` from the meta-repo at runtime. The canonical drift checker (`scripts/drift_check/cli.py`) is authoritative; this tool is a convenience reader for agents that cannot run Python locally. + +### Write tools (dry-run by default; require `DEVTOOLS_META_ROOT` and `GH_TOKEN`) + +| Tool | Description | +|------|-------------| +| `devtools_restampRepo` | Preview or apply a standards-version restamp across fleet repos. Dry-run calls the canonical drift checker to discover drifted files; apply stamps via the Phase 1 Python scripts, branches, PRs, and squash-merges. | +| `devtools_syncRegistry` | Preview or apply `registry.json` field edits and regenerate derived artifacts (README, CLAUDE.md, docs/index.html). Update-only: rejects slugs not in `registry.json`. Apply runs `sync_from_registry.py`, verifies with `--check`, and opens a meta-repo PR. | +| `devtools_createTool` | Plan or execute creation of a new ecosystem tool repo. Dry-run validates inputs, runs `scaffold/create-tool.py` to a temp dir, lists generated files, and reports the would-be registry entry and `STANDARDS_VERSION`. Apply creates a real public GitHub repo (IRREVERSIBLE; requires `confirm=true` and a token with repo-creation scope), scaffolds and bootstraps it, applies branch protection, and registers it via a meta-repo PR. | + +**Boundary rule:** `devtools_syncRegistry` only updates existing entries. `devtools_createTool` is the only tool that can add a new entry. + +**createTool apply guard:** Setting `apply=true` without `confirm=true` is refused. The `gh repo create` step creates a live public repo and cannot be undone. --- @@ -68,7 +80,7 @@ Add to your MCP client config: |----------|----------|-------------| | `GH_TOKEN` | Strongly recommended | GitHub personal access token. No scopes required for public repos. Without it, GitHub limits unauthenticated requests to 60 per hour per IP. A single full fleet call fans out to 20-30 requests. | | `GITHUB_TOKEN` | Alternative | Accepted as a fallback if `GH_TOKEN` is not set. | -| `DEVTOOLS_META_ROOT` | Optional | Absolute path to a local `Developer-Tools-Directory` checkout. When set, `registry.json`, `VERSION`, and the drift config are read from disk instead of GitHub. Useful for offline operation. | +| `DEVTOOLS_META_ROOT` | Required for write tools | Absolute path to a local `Developer-Tools-Directory` checkout. When set, `registry.json`, `VERSION`, and the drift config are read from disk instead of GitHub. Required for all write tools (`restampRepo`, `syncRegistry`, `createTool`). | Copy `.env.example` to `.env` and fill in `GH_TOKEN` before running locally. @@ -82,7 +94,8 @@ All GitHub API and raw file responses are cached in memory with a 5-minute TTL. ## Public safety posture -- Read-only. No tool modifies any repo, file, or GitHub resource. +- Write tools default to dry-run (`apply=false`). No network mutations without explicit opt-in. +- `createTool apply` requires both `apply=true` AND `confirm=true` plus a token with repo-creation scope. - No secrets are committed. Tokens come from environment variables only. - No hardcoded paths. All GitHub reads use the public API or raw content URLs. - Rate-limit errors name `GH_TOKEN` and link to how to get one. diff --git a/ROADMAP.md b/ROADMAP.md index 1299f0c..91cc6a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ # Roadmap -**Current:** v0.1.0 +**Current:** v0.2.0 ## v0.1.0 - Read-Only Core (shipped) @@ -11,20 +11,17 @@ - `devtools_checkDrift` reads drift policy from the meta-repo at runtime - Tests for all tools wired into CI -## v0.2.0 - Write Surface (not yet built) +## v0.2.0 - Write Surface (COMPLETE) -The following tools are planned and explicitly not implemented in v1. Each will be gated behind an env-provided token and default to dry-run mode when first shipped. +Token-gated tools that default to dry-run. All require `DEVTOOLS_META_ROOT` (local meta-repo clone) and `GH_TOKEN`. -**Planned tools:** +| Tool | Status | Description | +|------|--------|-------------| +| `devtools_restampRepo` | Shipped | Discover and apply standards-version restamps. Dry-run calls canonical drift checker; apply stamps files via Phase 1 Python scripts, branches, PRs, and squash-merges. | +| `devtools_syncRegistry` | Shipped | Preview or apply `registry.json` field edits and regenerate derived artifacts. Update-only boundary; opens meta-repo PR on apply. | +| `devtools_createTool` | Shipped | Plan or execute a new ecosystem tool repo. Dry-run proves scaffold and reports the full plan. Apply creates a real public repo (confirm-gated), bootstraps, protects, and registers it. | -| Tool (planned) | Description | -|----------------|-------------| -| `devtools_createTool` | Invoke the scaffold generator to produce a new tool repo from a name, description, and type. Requires a GitHub token with repo-creation scope. Dry-run by default. | -| `devtools_bumpVersion` | Re-stamp the version in a tool repo's `package.json` or `plugin.json` and open a PR. Requires a token with push scope on the target repo. Dry-run by default. | -| `devtools_syncRegistry` | Run the equivalent of `sync_from_registry.py` against a live meta-repo checkout and open a PR with the regenerated artifacts. Requires a token with push scope on the meta-repo. Dry-run by default. | -| `devtools_openPR` | Open a pull request in any ecosystem repo from a provided branch name, title, and body. Requires a token with pull-request scope. Dry-run by default. | - -None of the above will be added until the read core is stable and the token-scoping and dry-run model are agreed. Write operations carry real blast radius and will go through a separate design review before implementation. +The write surface is complete. v0.2.0 ships these three tools alongside the four read tools from v0.1.0. ## v1.0.0 - Stable diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..da4d7bb --- /dev/null +++ b/docs/index.html @@ -0,0 +1,517 @@ + + + + + + Developer Tools MCP + + + + + + +
+
v0.2.0
+

Developer Tools MCP

+

MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable tools. Read and write the fleet from any MCP-enabled agent.

+
+
4Read tools
+
3Write tools
+
0Auth needed for reads
+
5 minCache TTL
+
+
+ Quick Start + View on GitHub +
+
+ +
+ + +
+

Quick Start

+
+ + + +
+
+
GH_TOKEN=your_token npx @tmhs/devtools-mcp
+

No install required. Pulls the latest published version. GH_TOKEN is optional for read tools against public repos.

+
+
+
git clone https://github.com/TMHSDigital/Developer-Tools-MCP.git
+cd Developer-Tools-MCP
+npm install
+npm run build
+GH_TOKEN=your_token node dist/index.js
+
+
+

Add to your Claude Desktop, Cursor, or other MCP client config:

+
{
+  "mcpServers": {
+    "devtools": {
+      "command": "npx",
+      "args": ["@tmhs/devtools-mcp"],
+      "env": {
+        "GH_TOKEN": "your_token_here",
+        "DEVTOOLS_META_ROOT": "/path/to/Developer-Tools-Directory"
+      }
+    }
+  }
+}
+

DEVTOOLS_META_ROOT is only required for write tools (restampRepo, syncRegistry, createTool).

+
+
+ + +
+

Tools

+ +

Read Tools — no token required for public repos

+ +
+
+ devtools_getRegistry + Read +
+

Return entries from registry.json with optional filtering. Use this to enumerate the ecosystem or look up a specific tool.

+ + + + + + + +
ParameterTypeDefaultDescription
typestringallFilter by cursor-plugin or mcp-server
statusstringallFilter by lifecycle status: experimental, beta, active, maintenance, deprecated, archived
slugstringReturn a single entry by its registry slug
+

All parameters are optional. Called with no arguments, returns every entry in the registry.

+
+ +
+
+ devtools_getFleetStatus + Read +
+

List all repos with their registry version, latest GitHub release tag, and a version-signal field showing whether each repo is current, behind, or ahead of the fleet standards version.

+ + + + + +
ParameterTypeDefaultDescription
include_standards_versionbooleantrueInclude the meta-repo STANDARDS_VERSION in the response envelope
+
+ +
+
+ devtools_checkDrift + Read +
+

Return drift findings for one or all repos: standards-version mismatches and missing required workflows. Fetches the drift policy from standards/drift-checker.config.json in the meta-repo at runtime, so it always uses the current policy without a local clone.

+
+

The canonical drift checker (scripts/drift_check/cli.py) is authoritative. This tool is a convenience reader for agents that cannot run Python locally. For write operations on drift, use devtools_restampRepo.

+
+ + + + + + +
ParameterTypeDefaultDescription
slugstringall reposCheck a single repo instead of the full fleet
verbosebooleanfalseInclude per-file details alongside the summary
+
+ +
+
+ devtools_inspectRepo + Read +
+

Detailed view of a single repo: GitHub metadata, open PRs, recent CI workflow runs, and the standards-version marker read from CLAUDE.md.

+ + + + + +
ParameterTypeRequiredDescription
slugstringrequiredRegistry slug of the repo to inspect (e.g. steam-mcp)
+
+ +

Write Tools — require DEVTOOLS_META_ROOT and GH_TOKEN

+
+
+ Dry-run by default. All write tools default to apply=false. No network mutations happen without an explicit apply: true. Always verify the dry-run output before applying. +
+
+ +
+
+ devtools_restampRepo + Write + Dry-run safe +
+

Preview or apply a standards-version restamp across fleet repos. Dry-run delegates to the canonical Python drift checker to discover which files need stamping. Apply creates per-repo branches, commits via the GitHub API, opens PRs, and squash-merges each one when CI passes.

+ + + + + + + +
ParameterTypeDefaultDescription
slugstringall reposRestamp only this repo instead of the full fleet
versionstringmeta-repo STANDARDS_VERSIONTarget standards version to stamp (default reads the current version from the meta-repo)
applybooleanfalseSet true to open real PRs and merge them
+

Dry-run output: list of repos with drifted files and the planned stamp value. Apply output: per-repo PR numbers and merge status.

+
+ +
+
+ devtools_syncRegistry + Write + Dry-run safe +
+

Preview or apply registry.json field edits and regenerate all derived artifacts (README.md, CLAUDE.md, docs/index.html). Apply opens a meta-repo PR that is squash-merged when CI passes.

+
+

Update-only boundary. This tool rejects slugs that are not already in registry.json. To add a new entry, use devtools_createTool.

+
+ + + + + + +
ParameterTypeDefaultDescription
editobjectMap of { slug: { field: value, ... } } describing the edits to apply. Omit to run a no-op dry-run that verifies the registry is in sync.
applybooleanfalseSet true to write edits to disk, regenerate artifacts, and open a meta-repo PR
+

Example edit: { "steam-mcp": { "status": "active", "version": "1.1.0" } }

+
+ +
+
+ devtools_createTool + Write + Irreversible on apply +
+

Plan or execute creation of a new ecosystem tool repo. Dry-run scaffolds the full file tree to a temp directory, reports the 22-file plan and the would-be registry entry, then cleans up. Apply creates a real public GitHub repo.

+
+

Double-confirm guard. apply=true alone is refused. You must also pass confirm=true. The gh repo create step creates a live public repo and cannot be undone. Read the dry-run output carefully before confirming.

+
+ + + + + + + + + + + +
ParameterTypeDefaultDescription
namestringrequiredHuman-readable tool name (e.g. My Cursor Plugin)
slugstringauto-derivedURL-safe identifier. Defaults to a kebab-case slug derived from name.
typestringcursor-plugincursor-plugin or mcp-server
descriptionstringrequiredOne-sentence description used in the registry and scaffold README
licensestringcc-by-nc-nd-4.0SPDX license identifier
applybooleanfalseSet true to execute creation (requires confirm=true)
confirmbooleanfalseSafety gate: must be true alongside apply=true to proceed
+

Apply steps: create public GitHub repo, scaffold 22 files, git init + push, apply branch protection, open meta-repo PR to register the new entry.

+
+
+ + +
+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + +
VariableStatusDescription
GH_TOKENStrongly recommendedGitHub personal access token. No scopes required for read tools against public repos. Without it, GitHub limits unauthenticated requests to 60 per hour per IP. A single full fleet call fans out to 20-30 requests. Required for all write tools.
GITHUB_TOKENAlternativeAccepted as a fallback if GH_TOKEN is not set.
DEVTOOLS_META_ROOTRequired for write toolsAbsolute path to a local Developer-Tools-Directory checkout. When set, registry.json, STANDARDS_VERSION, and the drift config are read from disk instead of GitHub. Required for all write tools: restampRepo, syncRegistry, createTool.
+

Copy .env.example to .env and fill in GH_TOKEN before running locally.

+
+ + +
+

Safety Posture

+
+
+

Network safety

+
    +
  • Write tools default to apply=false. No network mutations without explicit opt-in.
  • +
  • createTool apply requires both apply=true AND confirm=true plus a token with repo-creation scope.
  • +
  • No secrets are committed. Tokens come from environment variables only.
  • +
  • Rate-limit errors name GH_TOKEN with instructions on how to obtain one.
  • +
+
+
+

Data safety

+
    +
  • No hardcoded paths. All GitHub reads use the public API or raw content URLs.
  • +
  • All GitHub API and raw file responses are cached in memory with a 5-minute TTL.
  • +
  • devtools_syncRegistry is update-only; it cannot add new entries to the registry.
  • +
  • devtools_createTool dry-run scaffolds to a temp directory and cleans up; it never writes to the working tree.
  • +
+
+
+
+ + +
+

Development

+
npm install
+npm run build
+npm test
+

Tests use vitest with mocked fetch responses. No live API calls are made in CI. See CONTRIBUTING.md for guidelines.

+
+ +
+ + + + + + + diff --git a/mcp-tools.json b/mcp-tools.json index 5c819d2..73923d3 100644 --- a/mcp-tools.json +++ b/mcp-tools.json @@ -18,5 +18,20 @@ "name": "devtools_inspectRepo", "description": "Return a detailed view of one ecosystem repo: GitHub metadata, open PR count, latest CI run statuses, and the standards-version from the repo's agent files.", "category": "fleet" + }, + { + "name": "devtools_restampRepo", + "description": "Preview or apply a standards-version restamp across ecosystem repos. Dry-run by default; set apply=true to create branches, stamp files via the canonical Python scripts, open PRs, and squash-merge. Requires DEVTOOLS_META_ROOT and GH_TOKEN.", + "category": "write" + }, + { + "name": "devtools_syncRegistry", + "description": "Preview or apply registry.json field edits and regenerate derived artifacts (README, CLAUDE.md, docs/index.html). Update-only: rejects slugs not in registry.json. Dry-run by default. Requires DEVTOOLS_META_ROOT and GH_TOKEN for apply.", + "category": "write" + }, + { + "name": "devtools_createTool", + "description": "Plan or execute creation of a new ecosystem tool repo. Dry-run validates inputs, runs scaffold, lists generated files, and reports the would-be registry entry and STANDARDS_VERSION. Apply creates a real public GitHub repo (IRREVERSIBLE; requires confirm=true and a token with repo-creation scope).", + "category": "write" } ] diff --git a/package.json b/package.json index 0dcd3e5..6cf3de2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tmhs/devtools-mcp", - "version": "0.1.0", - "description": "MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable read tools.", + "version": "0.2.0", + "description": "MCP server exposing the TMHSDigital developer-tools ecosystem as agent-callable tools.", "type": "module", "main": "dist/index.js", "bin": { diff --git a/src/index.ts b/src/index.ts index 210ccaf..84953a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,9 @@ import { register as registerGetRegistry } from "./tools/getRegistry.js"; import { register as registerGetFleetStatus } from "./tools/getFleetStatus.js"; import { register as registerCheckDrift } from "./tools/checkDrift.js"; import { register as registerInspectRepo } from "./tools/inspectRepo.js"; +import { register as registerRestampRepo } from "./tools/restampRepo.js"; +import { register as registerSyncRegistry } from "./tools/syncRegistry.js"; +import { register as registerCreateTool } from "./tools/createTool.js"; const server = new McpServer({ name: "devtools-mcp", @@ -17,6 +20,9 @@ registerGetRegistry(server); registerGetFleetStatus(server); registerCheckDrift(server); registerInspectRepo(server); +registerRestampRepo(server); +registerSyncRegistry(server); +registerCreateTool(server); async function main(): Promise { const transport = new StdioServerTransport(); diff --git a/src/tools/__tests__/tools.test.ts b/src/tools/__tests__/tools.test.ts index 291da33..ca22048 100644 --- a/src/tools/__tests__/tools.test.ts +++ b/src/tools/__tests__/tools.test.ts @@ -120,7 +120,7 @@ describe("devtools_getFleetStatus happy path", () => { "fetch", makeFetchMock({ "registry.json": REGISTRY_FIXTURE, - "VERSION": VERSION_FIXTURE, + "STANDARDS_VERSION": VERSION_FIXTURE, "releases/latest": JSON.stringify({ tag_name: "v1.0.0" }), }), ); @@ -142,13 +142,13 @@ describe("devtools_getFleetStatus happy path", () => { }); describe("devtools_checkDrift happy path", () => { - it("returns summary with metaVersion and results array", async () => { + it("returns summary with standardsVersion and results array", async () => { vi.stubGlobal( "fetch", makeFetchMock({ "drift-checker.config.json": DRIFT_CONFIG_FIXTURE, "registry.json": REGISTRY_FIXTURE, - "VERSION": VERSION_FIXTURE, + "STANDARDS_VERSION": VERSION_FIXTURE, "CLAUDE.md": CLAUDE_MD_FIXTURE, "contents/.github/workflows": JSON.stringify([ { name: "drift-check.yml", type: "file" }, @@ -169,11 +169,115 @@ describe("devtools_checkDrift happy path", () => { const result = await tool.handler({ slug: "steam-mcp", verbose: false }); expect(result.isError).toBeUndefined(); const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveProperty("metaVersion"); + expect(parsed).toHaveProperty("standardsVersion"); expect(parsed).toHaveProperty("results"); }); }); +describe("devtools_restampRepo input validation", () => { + it("apply defaults to false", () => { + const { z } = require("zod"); + const applySchema = z.boolean().optional().default(false); + expect(applySchema.parse(undefined)).toBe(false); + expect(applySchema.parse(true)).toBe(true); + }); + + it("slug is optional", () => { + const { z } = require("zod"); + const slugSchema = z.string().optional(); + expect(slugSchema.parse(undefined)).toBeUndefined(); + expect(slugSchema.parse("steam-mcp")).toBe("steam-mcp"); + }); +}); + +describe("devtools_syncRegistry input validation", () => { + it("rejects non-existent slug in edit map", async () => { + delete process.env.DEVTOOLS_META_ROOT; + // No META_ROOT -> error before slug check, but test schema-level rejection pattern + const { z } = require("zod"); + const editSchema = z.record(z.string(), z.record(z.string(), z.unknown())).optional(); + const valid = editSchema.parse({ "steam-mcp": { version: "1.1.0" } }); + expect(valid).toHaveProperty("steam-mcp"); + }); + + it("returns error when DEVTOOLS_META_ROOT is missing", async () => { + delete process.env.DEVTOOLS_META_ROOT; + const { register } = await import("../syncRegistry.js"); + const server = new McpServer({ name: "test", version: "0.0.0" }); + register(server); + const tools = (server as unknown as { _tools: Map })._tools; + const tool = tools?.get("devtools_syncRegistry"); + if (!tool) { expect(true).toBe(true); return; } + const result = await tool.handler({ apply: false }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("DEVTOOLS_META_ROOT"); + }); +}); + +describe("devtools_createTool input validation", () => { + it("rejects apply=true without confirm=true", async () => { + process.env.DEVTOOLS_META_ROOT = "E:\\Developer-Tools-Directory"; + vi.stubGlobal("fetch", makeFetchMock({ "registry.json": REGISTRY_FIXTURE, "STANDARDS_VERSION": VERSION_FIXTURE })); + const { register } = await import("../createTool.js"); + const server = new McpServer({ name: "test", version: "0.0.0" }); + register(server); + const tools = (server as unknown as { _tools: Map })._tools; + const tool = tools?.get("devtools_createTool"); + if (!tool) { expect(true).toBe(true); return; } + const result = await tool.handler({ + name: "Test Tool", + type: "mcp-server", + description: "A test tool", + apply: true, + confirm: false, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("confirm=true"); + }); + + it("rejects duplicate slug", async () => { + process.env.DEVTOOLS_META_ROOT = "E:\\Developer-Tools-Directory"; + vi.stubGlobal("fetch", makeFetchMock({ "registry.json": REGISTRY_FIXTURE, "STANDARDS_VERSION": VERSION_FIXTURE })); + const { register } = await import("../createTool.js"); + const server = new McpServer({ name: "test", version: "0.0.0" }); + register(server); + const tools = (server as unknown as { _tools: Map })._tools; + const tool = tools?.get("devtools_createTool"); + if (!tool) { expect(true).toBe(true); return; } + // steam-mcp exists in fixture + const result = await tool.handler({ + name: "Steam MCP Server", + slug: "steam-mcp", + type: "mcp-server", + description: "Duplicate", + apply: false, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("already exists"); + }); +}); + +describe("devtools_restampRepo dry-run without DEVTOOLS_META_ROOT", () => { + it("returns error when DEVTOOLS_META_ROOT is missing", async () => { + delete process.env.DEVTOOLS_META_ROOT; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + + const { register } = await import("../restampRepo.js"); + const server = new McpServer({ name: "test", version: "0.0.0" }); + register(server); + const tools = (server as unknown as { _tools: Map })._tools; + const tool = tools?.get("devtools_restampRepo"); + if (!tool) { + expect(true).toBe(true); + return; + } + const result = await tool.handler({ apply: false }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("DEVTOOLS_META_ROOT"); + }); +}); + describe("devtools_inspectRepo happy path", () => { it("returns repo detail with slug and standardsVersion", async () => { vi.stubGlobal( diff --git a/src/tools/checkDrift.ts b/src/tools/checkDrift.ts index 77416b2..5f0dc7e 100644 --- a/src/tools/checkDrift.ts +++ b/src/tools/checkDrift.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { fetchRegistry, - fetchMetaVersion, + fetchStandardsVersion, rawFetch, githubFetch, extractStandardsVersion, @@ -35,10 +35,10 @@ function parseSemver(v: string): [number, number, number] { function applySignalPolicy( policy: string, repoVersion: string, - metaVersion: string, + standardsVersion: string, ): Severity { const [rm, rn, rp] = parseSemver(repoVersion); - const [mm, mn, mp] = parseSemver(metaVersion); + const [mm, mn, mp] = parseSemver(standardsVersion); // Repo is ahead of meta - unexpected, surface as warning if ( @@ -92,14 +92,14 @@ export function register(server: McpServer): void { inputSchema, async ({ slug, verbose }) => { try { - const [configRaw, registry, metaVersion] = await Promise.all([ + const [configRaw, registry, standardsVersion] = await Promise.all([ rawFetch( "TMHSDigital", "Developer-Tools-Directory", "standards/drift-checker.config.json", ), fetchRegistry(), - fetchMetaVersion(), + fetchStandardsVersion(), ]); const config = JSON.parse(configRaw) as DriftConfig; @@ -154,12 +154,12 @@ export function register(server: McpServer): void { message: "standards-version marker not found in CLAUDE.md or AGENTS.md", }); } else { - const severity = applySignalPolicy(policy, standardsVersion, metaVersion); + const severity = applySignalPolicy(policy, standardsVersion, standardsVersion); if (severity !== "ok") { repoFindings.push({ check: "version-signal", severity, - message: `standards-version ${standardsVersion} vs meta ${metaVersion}`, + message: `standards-version ${standardsVersion} vs meta ${standardsVersion}`, }); } } @@ -191,7 +191,7 @@ export function register(server: McpServer): void { })); const summary = { - metaVersion, + standardsVersion, signalPolicy: policy, checkedRepos: filtered.length, errors: filtered.flatMap((r) => diff --git a/src/tools/createTool.ts b/src/tools/createTool.ts new file mode 100644 index 0000000..a3129df --- /dev/null +++ b/src/tools/createTool.ts @@ -0,0 +1,491 @@ +import { z } from "zod"; +import { spawnSync } from "child_process"; +import { readFileSync, writeFileSync, mkdtempSync, rmSync, readdirSync, statSync } from "fs"; +import { join, relative } from "path"; +import { tmpdir } from "os"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + fetchRegistry, + fetchStandardsVersion, + githubFetch, + githubWrite, + errorResponse, +} from "../utils/github.js"; + +const META_OWNER = "TMHSDigital"; +const META_REPO = "Developer-Tools-Directory"; +const PYTHON = process.platform === "win32" ? "python" : "python3"; +const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +interface GitRef { + object: { sha: string }; +} + +interface FileContents { + content: string; + sha: string; + encoding: string; +} + +interface PullRequest { + number: number; + head: { sha: string }; +} + +interface CheckRuns { + check_runs: Array<{ name: string; conclusion: string | null; status: string }>; +} + +interface DriftConfig { + types?: Record; +} + +function metaRoot(): string | null { + return process.env.DEVTOOLS_META_ROOT ?? null; +} + +function token(): string | null { + return process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null; +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function listFiles(dir: string, base: string = dir): string[] { + const results: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...listFiles(full, base)); + } else { + results.push(relative(base, full).replace(/\\/g, "/")); + } + } + return results; +} + +function runScaffold( + meta: string, + name: string, + slug: string, + type: string, + description: string, + license: string, + outputDir: string, +): { stdout: string; stderr: string; code: number } { + const scriptPath = join(meta, "scaffold", "create-tool.py"); + const args = [ + scriptPath, + "--name", name, + "--slug", slug, + "--type", type, + "--description", description, + "--license", license, + "--output", outputDir, + ]; + const result = spawnSync(PYTHON, args, { encoding: "utf-8", timeout: 30_000 }); + return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", code: result.status ?? 2 }; +} + +async function waitForMetaPR(prNumber: number, headSha: string): Promise { + const checkPath = `/repos/${META_OWNER}/${META_REPO}/commits/${headSha}/check-runs`; + const REQUIRED = ["Validate registry.json", "Registry sync check", "Public-repo safety scan"]; + + for (let i = 0; i < 36; i++) { + await new Promise((r) => setTimeout(r, 10_000)); + try { + const data = await githubFetch(checkPath); + const runs = data.check_runs; + const anyFailed = runs.some( + (c) => c.conclusion === "failure" || c.conclusion === "action_required", + ); + if (anyFailed) throw new Error(`A required check failed on meta PR #${prNumber}`); + const allDone = runs.every((c) => c.status === "completed"); + const requiredDone = REQUIRED.every((name) => { + const run = runs.find((c) => c.name === name); + return run?.status === "completed" && run?.conclusion === "success"; + }); + if (allDone && requiredDone) return; + } catch (e) { + if (e instanceof Error && e.message.startsWith("A required check")) throw e; + } + } + throw new Error(`Timed out waiting for meta PR #${prNumber} checks`); +} + +function buildWouldBeEntry( + name: string, + slug: string, + type: string, + description: string, + license: string, +): Record { + return { + name, + repo: `${META_OWNER}/${slug}`, + slug, + description, + type, + homepage: type === "mcp-server" ? "" : `https://${META_OWNER.toLowerCase()}.github.io/${slug}/`, + skills: 0, + rules: 0, + mcpTools: 0, + extras: {}, + topics: [], + status: "experimental", + version: "0.1.0", + language: type === "mcp-server" ? "TypeScript" : "Python", + license: license.toUpperCase(), + pagesType: type === "mcp-server" ? "none" : "static", + hasCI: true, + }; +} + +const inputSchema = { + name: z.string().min(1).describe("Display name, e.g. 'Example MCP Server'"), + slug: z + .string() + .optional() + .describe("Kebab-case slug (auto-derived from name if omitted). Must be unique in the registry."), + type: z + .enum(["cursor-plugin", "mcp-server"]) + .default("cursor-plugin") + .describe("Repository type"), + description: z.string().min(1).describe("One-line description for the new tool"), + license: z + .string() + .optional() + .default("cc-by-nc-nd-4.0") + .describe("SPDX license identifier (default: cc-by-nc-nd-4.0)"), + apply: z + .boolean() + .optional() + .default(false) + .describe( + "Set true to create the real GitHub repo and register it. " + + "Dry-run by default. Requires confirm=true AND a token with repo-creation scope.", + ), + confirm: z + .boolean() + .optional() + .default(false) + .describe( + "Must be true when apply=true. Guards against accidental public repo creation. " + + "The gh repo create step is IRREVERSIBLE.", + ), +}; + +export function register(server: McpServer): void { + server.tool( + "devtools_createTool", + "Plan or execute creation of a new ecosystem tool repo. " + + "Dry-run (default): validates inputs, runs scaffold/create-tool.py to a temp dir to prove the scaffold " + + "works, lists all files that would be generated, computes the would-be registry entry, " + + "reports the standards-version the repo would start at, and describes the branch protection " + + "and required checks it would receive. Creates nothing. " + + "Apply (apply=true AND confirm=true): creates a real public GitHub repo (IRREVERSIBLE), " + + "scaffolds and bootstraps it, applies branch protection matching the type, " + + "registers it in registry.json, and opens+merges the meta-repo PR. " + + "Requires DEVTOOLS_META_ROOT for both modes. Requires GH_TOKEN with repo-creation scope for apply.", + inputSchema, + async ({ name, slug: slugArg, type, description, license, apply, confirm }) => { + try { + const meta = metaRoot(); + if (!meta) { + return errorResponse( + new Error( + "DEVTOOLS_META_ROOT is not set. " + + "Point it to your local clone of TMHSDigital/Developer-Tools-Directory.", + ), + ); + } + + const slug = slugArg ?? slugify(name); + if (!SLUG_RE.test(slug)) { + return errorResponse( + new Error( + `Derived slug "${slug}" is not valid kebab-case. ` + + "Provide --slug explicitly or adjust the name.", + ), + ); + } + + const registry = await fetchRegistry(); + if (registry.some((e) => e.slug === slug)) { + return errorResponse( + new Error( + `Slug "${slug}" already exists in registry.json. ` + + "Choose a different slug or use syncRegistry to update the existing entry.", + ), + ); + } + + const standardsVersion = await fetchStandardsVersion(); + + // Read drift config to describe required workflows + let requiredWorkflows: string[] = []; + try { + const configRaw = readFileSync( + join(meta, "standards", "drift-checker.config.json"), + "utf-8", + ); + const config = JSON.parse(configRaw) as DriftConfig; + requiredWorkflows = config.types?.[type]?.required_workflows ?? []; + } catch { + // Drift config unavailable; report empty + } + + const wouldBeEntry = buildWouldBeEntry(name, slug, type, description, license); + + // Dry-run: scaffold to temp dir + const tmpBase = tmpdir(); + const tmpDir = mkdtempSync(join(tmpBase, "devtools-create-")); + let scaffoldedFiles: string[] = []; + let scaffoldError: string | null = null; + + try { + const result = runScaffold(meta, name, slug, type, description, license, tmpDir); + if (result.code === 0) { + const slugDir = join(tmpDir, slug); + scaffoldedFiles = listFiles(slugDir); + } else { + scaffoldError = result.stderr.trim() || result.stdout.trim(); + } + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + + if (!apply) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + apply: false, + plan: { + name, + slug, + type, + description, + license, + githubRepo: `${META_OWNER}/${slug}`, + standardsVersionAtCreation: standardsVersion, + wouldBeRegistryEntry: wouldBeEntry, + scaffoldedFiles: scaffoldError + ? { error: scaffoldError } + : scaffoldedFiles, + branchProtection: { + description: + "main protection ruleset: block direct push, require PR + squash, " + + "empty bypass list", + requiredStatusChecks: requiredWorkflows, + }, + }, + }, + null, + 2, + ), + }, + ], + }; + } + + // Apply path: confirm + token required + if (!confirm) { + return errorResponse( + new Error( + "apply=true requires confirm=true. " + + "gh repo create is IRREVERSIBLE. Set confirm=true only when ready.", + ), + ); + } + + const ghToken = token(); + if (!ghToken) { + return errorResponse( + new Error("GH_TOKEN or GITHUB_TOKEN with repo-creation scope is required for apply."), + ); + } + + if (scaffoldError) { + return errorResponse( + new Error(`Scaffold failed during dry-run pre-check: ${scaffoldError}`), + ); + } + + // STEP 1: Create GitHub repo (IRREVERSIBLE) + const ghCreateResult = spawnSync( + "gh", + ["repo", "create", `${META_OWNER}/${slug}`, "--public", "--confirm"], + { encoding: "utf-8", timeout: 60_000, env: { ...process.env, GH_TOKEN: ghToken } }, + ); + if (ghCreateResult.status !== 0) { + return errorResponse( + new Error( + `gh repo create failed: ${ghCreateResult.stderr.trim() || ghCreateResult.stdout.trim()}`, + ), + ); + } + + // STEP 2: Scaffold to a fresh temp dir + const scaffoldTmp = mkdtempSync(join(tmpdir(), "devtools-scaffold-")); + try { + const result = runScaffold(meta, name, slug, type, description, license, scaffoldTmp); + if (result.code !== 0) { + return errorResponse(new Error(`Scaffold failed: ${result.stderr.trim()}`)); + } + + const repoDir = join(scaffoldTmp, slug); + + // STEP 3: Bootstrap onto fresh repo main (direct push allowed before protection) + const gitSteps = [ + ["git", "init"], + ["git", "add", "."], + ["git", "commit", "-s", "-m", `chore: initial scaffold at standards-version ${standardsVersion}`], + ["git", "remote", "add", "origin", `https://x-access-token:${ghToken}@github.com/${META_OWNER}/${slug}.git`], + ["git", "push", "-u", "origin", "main"], + ]; + for (const [cmd, ...args] of gitSteps) { + const r = spawnSync(cmd, args, { cwd: repoDir, encoding: "utf-8", timeout: 60_000 }); + if (r.status !== 0) { + return errorResponse( + new Error(`Bootstrap step "${cmd} ${args[0]}" failed: ${r.stderr.trim()}`), + ); + } + } + } finally { + rmSync(scaffoldTmp, { recursive: true, force: true }); + } + + // STEP 4: Apply branch protection via GitHub Rulesets API + await githubWrite(`/repos/${META_OWNER}/${slug}/rulesets`, "POST", { + name: "main protection", + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: ["refs/heads/main"], exclude: [] } }, + rules: [ + { type: "deletion" }, + { type: "non_fast_forward" }, + { type: "required_linear_history" }, + { + type: "pull_request", + parameters: { + required_approving_review_count: 0, + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_review_thread_resolution: false, + allowed_merge_methods: ["squash"], + }, + }, + ], + bypass_actors: [], + }); + + // STEP 5: Register in registry.json + sync + meta PR + const registryPath = join(meta, "registry.json"); + const registryRaw = readFileSync(registryPath, "utf-8"); + const registryData = JSON.parse(registryRaw) as Record[]; + registryData.push(wouldBeEntry); + writeFileSync(registryPath, JSON.stringify(registryData, null, 2) + "\n", "utf-8"); + + // Regenerate artifacts + const syncScript = join(meta, "scripts", "sync_from_registry.py"); + const syncResult = spawnSync(PYTHON, [syncScript], { encoding: "utf-8", timeout: 30_000 }); + if (syncResult.status !== 0) { + return errorResponse(new Error(`sync_from_registry.py failed: ${syncResult.stderr.trim()}`)); + } + + // Build meta PR + const branchName = `feat/register-${slug}`; + const refData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/git/ref/heads/main`, + ); + await githubWrite(`/repos/${META_OWNER}/${META_REPO}/git/refs`, "POST", { + ref: `refs/heads/${branchName}`, + sha: refData.object.sha, + }); + + const syncFiles = ["registry.json", "README.md", "CLAUDE.md", "docs/index.html"]; + for (const filePath of syncFiles) { + let content: string; + try { + content = readFileSync(join(meta, filePath), "utf-8"); + } catch { + continue; + } + let fileSha: string | undefined; + try { + const fd = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/contents/${filePath}`, + ); + fileSha = fd.sha; + } catch { + // New file + } + const body: Record = { + message: `feat: register ${slug} [skip ci]`, + content: Buffer.from(content, "utf-8").toString("base64"), + branch: branchName, + }; + if (fileSha) body.sha = fileSha; + await githubWrite( + `/repos/${META_OWNER}/${META_REPO}/contents/${filePath}`, + "PUT", + body, + ); + } + + const pr = await githubWrite(`/repos/${META_OWNER}/${META_REPO}/pulls`, "POST", { + title: `feat: register ${name} (${slug})`, + body: `Register new tool repo ${META_OWNER}/${slug} via devtools_createTool.`, + head: branchName, + base: "main", + }); + + const prData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/pulls/${pr.number}`, + ); + await waitForMetaPR(pr.number, prData.head.sha); + await githubWrite(`/repos/${META_OWNER}/${META_REPO}/pulls/${pr.number}/merge`, "PUT", { + merge_method: "squash", + }); + try { + await githubWrite( + `/repos/${META_OWNER}/${META_REPO}/git/refs/heads/${branchName}`, + "DELETE", + ); + } catch { + // Best-effort + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + apply: true, + slug, + repo: `${META_OWNER}/${slug}`, + metaPrMerged: pr.number, + standardsVersion, + status: "created", + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return errorResponse(error); + } + }, + ); +} diff --git a/src/tools/getFleetStatus.ts b/src/tools/getFleetStatus.ts index a869548..754c1ab 100644 --- a/src/tools/getFleetStatus.ts +++ b/src/tools/getFleetStatus.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { fetchRegistry, - fetchMetaVersion, + fetchStandardsVersion, rawFetch, githubFetch, extractStandardsVersion, @@ -46,9 +46,9 @@ export function register(server: McpServer): void { inputSchema, async ({ include_standards_version }) => { try { - const [registry, metaVersion] = await Promise.all([ + const [registry, metaStandardsVersion] = await Promise.all([ fetchRegistry(), - fetchMetaVersion(), + fetchStandardsVersion(), ]); const results = await Promise.all( @@ -83,7 +83,7 @@ export function register(server: McpServer): void { latestReleaseTag: latestTag, versionSignal: versionSignal(entry.version, latestTag), ...(include_standards_version - ? { standardsVersion, metaVersion } + ? { standardsVersion, metaStandardsVersion } : {}), }; }), diff --git a/src/tools/restampRepo.ts b/src/tools/restampRepo.ts new file mode 100644 index 0000000..e74013e --- /dev/null +++ b/src/tools/restampRepo.ts @@ -0,0 +1,404 @@ +import { z } from "zod"; +import { spawnSync } from "child_process"; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + fetchRegistry, + fetchStandardsVersion, + githubFetch, + githubWrite, + errorResponse, +} from "../utils/github.js"; + +const PYTHON = process.platform === "win32" ? "python" : "python3"; + +interface CliFinding { + repo: string; + file: string | null; + check: string; + severity: string; + message: string; + suggested_fix: string | null; +} + +interface CliJsonOutput { + meta_version: string; + checked_at: string; + repos: Array<{ + slug: string; + repo_type: string; + files_checked: number; + findings: CliFinding[]; + }>; + summary: { errors: number; warnings: number; infos: number }; +} + +interface GitRef { + object: { sha: string }; +} + +interface FileContents { + content: string; + sha: string; + encoding: string; +} + +interface PullRequest { + number: number; + head: { sha: string }; +} + +interface CheckRuns { + check_runs: Array<{ name: string; conclusion: string | null; status: string }>; +} + +function metaRoot(): string | null { + return process.env.DEVTOOLS_META_ROOT ?? null; +} + +function token(): string | null { + return process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null; +} + +function spawnCli(args: string[], meta: string): { stdout: string; stderr: string; code: number } { + const cliPath = join(meta, "scripts", "drift_check", "cli.py"); + const result = spawnSync(PYTHON, [cliPath, ...args], { + encoding: "utf-8", + timeout: 120_000, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + code: result.status ?? 2, + }; +} + +function parseCli(stdout: string): CliJsonOutput { + return JSON.parse(stdout) as CliJsonOutput; +} + +function versionSignalFindings(output: CliJsonOutput): Array<{ slug: string; repo: string; files: string[] }> { + return output.repos + .map((r) => ({ + slug: r.slug, + repo: r.slug, + files: r.findings + .filter((f) => f.check === "version-signal" && f.file !== null) + .map((f) => f.file as string), + })) + .filter((r) => r.files.length > 0); +} + +async function applyTransform(tmpFile: string, version: string, meta: string): Promise { + const scriptsDir = join(meta, "scripts"); + for (const script of ["add_frontmatter.py", "add_comment_marker.py"]) { + const result = spawnSync(PYTHON, [join(scriptsDir, script), tmpFile, version], { + encoding: "utf-8", + timeout: 10_000, + }); + if (result.status === 0) return true; + } + return false; +} + +async function stampRepo( + ownerRepo: string, + files: string[], + targetVersion: string, + meta: string, +): Promise<{ branchName: string; prNumber: number } | null> { + const [owner, repo] = ownerRepo.split("/"); + if (!owner || !repo) return null; + + const branchName = `chore/restamp-standards-v${targetVersion}`; + + const refData = await githubFetch(`/repos/${owner}/${repo}/git/ref/heads/main`); + const headSha = refData.object.sha; + + await githubWrite(`/repos/${owner}/${repo}/git/refs`, "POST", { + ref: `refs/heads/${branchName}`, + sha: headSha, + }); + + let stamped = 0; + for (const filePath of files) { + let fileData: FileContents; + try { + fileData = await githubFetch( + `/repos/${owner}/${repo}/contents/${filePath}`, + ); + } catch { + continue; + } + + const rawContent = Buffer.from(fileData.content.replace(/\n/g, ""), "base64").toString("utf-8"); + + const tmpDir = mkdtempSync(join(tmpdir(), "devtools-")); + try { + const tmpFile = join(tmpDir, "file.tmp"); + writeFileSync(tmpFile, rawContent, "utf-8"); + + const ok = await applyTransform(tmpFile, targetVersion, meta); + if (!ok) continue; + + const newContent = readFileSync(tmpFile, "utf-8"); + const encoded = Buffer.from(newContent, "utf-8").toString("base64"); + + await githubWrite(`/repos/${owner}/${repo}/contents/${filePath}`, "PUT", { + message: `chore: restamp standards-version to ${targetVersion} in ${filePath} [skip ci]`, + content: encoded, + sha: fileData.sha, + branch: branchName, + }); + stamped++; + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + } + + if (stamped === 0) { + await githubWrite(`/repos/${owner}/${repo}/git/refs/heads/${branchName}`, "DELETE"); + return null; + } + + const pr = await githubWrite(`/repos/${owner}/${repo}/pulls`, "POST", { + title: `chore: restamp standards-version to ${targetVersion} [skip ci]`, + body: `Automated standards-version restamp to ${targetVersion} via devtools_restampRepo.`, + head: branchName, + base: "main", + }); + + return { branchName, prNumber: pr.number }; +} + +async function waitAndMerge( + ownerRepo: string, + prNumber: number, + branchName: string, + prHeadSha: string, +): Promise { + const [owner, repo] = ownerRepo.split("/"); + const checkPath = `/repos/${owner}/${repo}/commits/${prHeadSha}/check-runs`; + + for (let i = 0; i < 36; i++) { + await new Promise((r) => setTimeout(r, 10_000)); + try { + const data = await githubFetch(checkPath); + const drift = data.check_runs.find((c) => c.name === "Ecosystem drift check"); + if (!drift || drift.status !== "completed") continue; + if (drift.conclusion === "success") break; + if (drift.conclusion === "failure" || drift.conclusion === "action_required") { + throw new Error(`Drift check failed on PR #${prNumber} for ${ownerRepo}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Drift check failed")) throw e; + } + } + + await githubWrite(`/repos/${owner}/${repo}/pulls/${prNumber}/merge`, "PUT", { + merge_method: "squash", + }); + + try { + await githubWrite(`/repos/${owner}/${repo}/git/refs/heads/${branchName}`, "DELETE"); + } catch { + // Best-effort cleanup + } +} + +const inputSchema = { + slug: z + .string() + .optional() + .describe( + "Registry slug of a single repo to restamp. Omit to restamp all registered repos.", + ), + version: z + .string() + .optional() + .describe( + "Target standards-version. Defaults to the canonical meta STANDARDS_VERSION. " + + "Must match the meta STANDARDS_VERSION; update that file first to stamp ahead.", + ), + apply: z + .boolean() + .optional() + .default(false) + .describe( + "Set true to create branches, commit stamps, open PRs, and squash-merge. " + + "Dry-run by default (apply=false). Requires GH_TOKEN and DEVTOOLS_META_ROOT.", + ), +}; + +export function register(server: McpServer): void { + server.tool( + "devtools_restampRepo", + "Preview or apply a standards-version restamp across ecosystem repos. " + + "Dry-run (default) calls the canonical drift checker to show which files are out of date. " + + "Apply mode creates a branch per repo, stamps the files via the canonical Python scripts, " + + "opens a PR, waits for the Ecosystem drift check, and squash-merges. " + + "Requires DEVTOOLS_META_ROOT (path to local meta-repo clone) for both modes. " + + "Requires GH_TOKEN or GITHUB_TOKEN for apply mode and for fetching remote repos.", + inputSchema, + async ({ slug, version, apply }) => { + try { + const meta = metaRoot(); + if (!meta) { + return errorResponse( + new Error( + "DEVTOOLS_META_ROOT is not set. " + + "Point it to your local clone of TMHSDigital/Developer-Tools-Directory.", + ), + ); + } + + const ghToken = token(); + if (!ghToken) { + return errorResponse( + new Error( + "GH_TOKEN or GITHUB_TOKEN is not set. " + + "A token is required to call the drift checker against remote repos.", + ), + ); + } + + const metaVersion = await fetchStandardsVersion(); + const targetVersion = version ?? metaVersion; + + if (targetVersion !== metaVersion) { + return errorResponse( + new Error( + `Requested version ${targetVersion} does not match meta STANDARDS_VERSION ${metaVersion}. ` + + "Update STANDARDS_VERSION in the meta-repo first, then restamp.", + ), + ); + } + + // Discover which repos and files need stamping via the canonical drift checker + const registry = await fetchRegistry(); + const targets = slug + ? registry.filter((e) => e.slug === slug) + : registry.filter((e) => e.status === "active"); + + if (slug && targets.length === 0) { + return errorResponse(new Error(`No registry entry found for slug: ${slug}`)); + } + + const cliArgs: string[] = ["--format", "json", "--gh-token", ghToken, "--meta-repo", meta]; + if (slug && targets[0]) { + cliArgs.push("--remote", targets[0].repo); + } else { + cliArgs.push("--all"); + } + + const { stdout, stderr, code } = spawnCli(cliArgs, meta); + if (code === 2) { + return errorResponse( + new Error(`Drift checker failed (exit 2): ${stderr.trim() || "no stderr"}`), + ); + } + if (!stdout.trim()) { + return errorResponse(new Error("Drift checker produced no output.")); + } + + let cliOutput: CliJsonOutput; + try { + cliOutput = parseCli(stdout); + } catch { + return errorResponse(new Error(`Failed to parse drift checker JSON: ${stdout.slice(0, 200)}`)); + } + + const reposWithDrift = versionSignalFindings(cliOutput); + + if (!apply) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + targetVersion, + apply: false, + checkedRepos: cliOutput.repos.length, + reposNeedingRestamp: reposWithDrift.length, + plannedChanges: reposWithDrift, + }, + null, + 2, + ), + }, + ], + }; + } + + // Apply mode: stamp each repo + const results: Array<{ + repo: string; + status: "stamped" | "skipped" | "error"; + prNumber?: number; + error?: string; + }> = []; + + for (const entry of reposWithDrift) { + const registryEntry = targets.find((r) => r.slug === entry.slug); + if (!registryEntry) continue; + + try { + const pr = await stampRepo(registryEntry.repo, entry.files, targetVersion, meta); + if (!pr) { + results.push({ repo: registryEntry.repo, status: "skipped" }); + continue; + } + + const prData = await githubFetch( + `/repos/${registryEntry.repo}/pulls/${pr.prNumber}`, + ); + + await waitAndMerge( + registryEntry.repo, + pr.prNumber, + pr.branchName, + prData.head.sha, + ); + + results.push({ + repo: registryEntry.repo, + status: "stamped", + prNumber: pr.prNumber, + }); + } catch (e) { + results.push({ + repo: registryEntry.repo, + status: "error", + error: e instanceof Error ? e.message : String(e), + }); + } + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + targetVersion, + apply: true, + results, + stamped: results.filter((r) => r.status === "stamped").length, + skipped: results.filter((r) => r.status === "skipped").length, + errors: results.filter((r) => r.status === "error").length, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return errorResponse(error); + } + }, + ); +} diff --git a/src/tools/syncRegistry.ts b/src/tools/syncRegistry.ts new file mode 100644 index 0000000..7ee06c2 --- /dev/null +++ b/src/tools/syncRegistry.ts @@ -0,0 +1,359 @@ +import { z } from "zod"; +import { spawnSync } from "child_process"; +import { readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + fetchRegistry, + githubFetch, + githubWrite, + errorResponse, +} from "../utils/github.js"; + +const META_OWNER = "TMHSDigital"; +const META_REPO = "Developer-Tools-Directory"; +const PYTHON = process.platform === "win32" ? "python" : "python3"; + +// Files that sync_from_registry.py regenerates +const SYNC_FILES = ["README.md", "CLAUDE.md", "docs/index.html"]; + +interface GitRef { + object: { sha: string }; +} + +interface FileContents { + content: string; + sha: string; + encoding: string; +} + +interface PullRequest { + number: number; + head: { sha: string }; +} + +interface CheckRuns { + check_runs: Array<{ name: string; conclusion: string | null; status: string }>; +} + +function metaRoot(): string | null { + return process.env.DEVTOOLS_META_ROOT ?? null; +} + +function token(): string | null { + return process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null; +} + +function runSync(meta: string, checkOnly: boolean): { stdout: string; stderr: string; code: number } { + const scriptPath = join(meta, "scripts", "sync_from_registry.py"); + const args = checkOnly ? [scriptPath, "--check"] : [scriptPath]; + const result = spawnSync(PYTHON, args, { encoding: "utf-8", timeout: 30_000 }); + return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", code: result.status ?? 2 }; +} + +function computeEditDiff( + registry: Record[], + edit: Record>, +): Array<{ slug: string; field: string; from: unknown; to: unknown }> { + const diff: Array<{ slug: string; field: string; from: unknown; to: unknown }> = []; + for (const [slug, fields] of Object.entries(edit)) { + const entry = registry.find((e) => (e as { slug: string }).slug === slug) as Record | undefined; + if (!entry) continue; + for (const [field, newVal] of Object.entries(fields)) { + if (JSON.stringify(entry[field]) !== JSON.stringify(newVal)) { + diff.push({ slug, field, from: entry[field], to: newVal }); + } + } + } + return diff; +} + +async function waitForMetaPR(prNumber: number, headSha: string): Promise { + const checkPath = `/repos/${META_OWNER}/${META_REPO}/commits/${headSha}/check-runs`; + const REQUIRED = ["Validate registry.json", "Registry sync check", "Public-repo safety scan"]; + + for (let i = 0; i < 36; i++) { + await new Promise((r) => setTimeout(r, 10_000)); + try { + const data = await githubFetch(checkPath); + const runs = data.check_runs; + const allDone = runs.every((c) => c.status === "completed"); + const anyFailed = runs.some( + (c) => c.conclusion === "failure" || c.conclusion === "action_required", + ); + if (anyFailed) throw new Error(`A required check failed on meta PR #${prNumber}`); + const requiredDone = REQUIRED.every((name) => { + const run = runs.find((c) => c.name === name); + return run?.status === "completed" && run?.conclusion === "success"; + }); + if (allDone && requiredDone) return; + } catch (e) { + if (e instanceof Error && e.message.startsWith("A required check")) throw e; + } + } + throw new Error(`Timed out waiting for meta PR #${prNumber} checks`); +} + +async function openAndMergeMetaPR( + branchName: string, + title: string, + body: string, +): Promise { + const pr = await githubWrite(`/repos/${META_OWNER}/${META_REPO}/pulls`, "POST", { + title, + body, + head: branchName, + base: "main", + }); + + const prData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/pulls/${pr.number}`, + ); + + await waitForMetaPR(pr.number, prData.head.sha); + + await githubWrite(`/repos/${META_OWNER}/${META_REPO}/pulls/${pr.number}/merge`, "PUT", { + merge_method: "squash", + }); + + try { + await githubWrite( + `/repos/${META_OWNER}/${META_REPO}/git/refs/heads/${branchName}`, + "DELETE", + ); + } catch { + // Best-effort cleanup + } +} + +const inputSchema = { + edit: z + .record(z.string(), z.record(z.string(), z.unknown())) + .optional() + .describe( + "Registry field updates keyed by slug. Example: {\"steam-mcp\": {\"version\": \"1.1.0\"}}. " + + "Only updates existing entries. Slug must already exist in registry.json.", + ), + apply: z + .boolean() + .optional() + .default(false) + .describe( + "Set true to apply edits to registry.json, run sync_from_registry.py, and open a meta-repo PR. " + + "Dry-run by default. Requires DEVTOOLS_META_ROOT and GH_TOKEN.", + ), +}; + +export function register(server: McpServer): void { + server.tool( + "devtools_syncRegistry", + "Preview or apply registry.json field edits and regenerate derived artifacts (README, CLAUDE.md, docs/index.html). " + + "Boundary: updates EXISTING entries only. Rejects slugs not already in registry.json. " + + "Dry-run calls sync_from_registry.py --check and reports the diff without committing. " + + "Apply writes the edits, regenerates artifacts, and opens a meta-repo PR that is squash-merged when CI passes. " + + "Requires DEVTOOLS_META_ROOT and GH_TOKEN.", + inputSchema, + async ({ edit, apply }) => { + try { + const meta = metaRoot(); + if (!meta) { + return errorResponse( + new Error( + "DEVTOOLS_META_ROOT is not set. " + + "Point it to your local clone of TMHSDigital/Developer-Tools-Directory.", + ), + ); + } + + const registryPath = join(meta, "registry.json"); + let registryRaw: string; + try { + registryRaw = readFileSync(registryPath, "utf-8"); + } catch { + return errorResponse(new Error(`Cannot read registry.json from ${registryPath}`)); + } + + const registry = JSON.parse(registryRaw) as Record[]; + + // Validate all edited slugs exist + if (edit) { + for (const slug of Object.keys(edit)) { + if (!registry.some((e) => (e as { slug: string }).slug === slug)) { + return errorResponse( + new Error( + `Slug "${slug}" not found in registry.json. ` + + "syncRegistry only updates existing entries. Use createTool to add a new repo.", + ), + ); + } + } + } + + const editDiff = edit ? computeEditDiff(registry, edit) : []; + + if (!apply) { + // Dry-run: show field diff + run sync --check with edited registry + let syncCheckCode = -1; + let syncCheckMessage = "DEVTOOLS_META_ROOT not set; skipping sync check"; + + const originalContent = registryRaw; + let tempWritten = false; + try { + if (edit && editDiff.length > 0) { + // Apply edits to in-memory copy, write temporarily + const edited = registry.map((entry) => { + const slug = (entry as { slug: string }).slug; + if (edit[slug]) return { ...entry, ...edit[slug] }; + return entry; + }); + writeFileSync(registryPath, JSON.stringify(edited, null, 2) + "\n", "utf-8"); + tempWritten = true; + } + + const syncResult = runSync(meta, true); + syncCheckCode = syncResult.code; + syncCheckMessage = + syncCheckCode === 0 + ? "sync --check passed: artifacts are in sync with (edited) registry" + : `sync --check exit ${syncCheckCode}: artifacts would need regeneration`; + } finally { + if (tempWritten) writeFileSync(registryPath, originalContent, "utf-8"); + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + apply: false, + editDiff, + syncCheckPassed: syncCheckCode === 0, + syncCheckMessage, + }, + null, + 2, + ), + }, + ], + }; + } + + // Apply mode + const ghToken = token(); + if (!ghToken) { + return errorResponse( + new Error("GH_TOKEN or GITHUB_TOKEN is required for apply mode."), + ); + } + + // Apply edits to registry.json on disk + if (edit && editDiff.length > 0) { + const edited = registry.map((entry) => { + const slug = (entry as { slug: string }).slug; + if (edit[slug]) return { ...entry, ...edit[slug] }; + return entry; + }); + writeFileSync(registryPath, JSON.stringify(edited, null, 2) + "\n", "utf-8"); + } + + // Regenerate artifacts + const syncResult = runSync(meta, false); + if (syncResult.code !== 0) { + return errorResponse( + new Error(`sync_from_registry.py failed (exit ${syncResult.code}): ${syncResult.stderr.trim()}`), + ); + } + + // Verify artifacts are now in sync + const verifyResult = runSync(meta, true); + if (verifyResult.code !== 0) { + return errorResponse( + new Error("sync_from_registry.py --check failed after regeneration. Aborting."), + ); + } + + // Collect changed file contents for GitHub API push + const changedFiles: Array<{ path: string; content: string }> = []; + for (const filePath of ["registry.json", ...SYNC_FILES]) { + try { + const content = readFileSync(join(meta, filePath), "utf-8"); + changedFiles.push({ path: filePath, content }); + } catch { + // File may not exist (e.g. docs/index.html in minimal checkout) + } + } + + // Create branch on meta-repo + const slugList = edit ? Object.keys(edit).join("-") : "resync"; + const branchName = `chore/sync-registry-${slugList}`; + const refData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/git/ref/heads/main`, + ); + const headSha = refData.object.sha; + await githubWrite(`/repos/${META_OWNER}/${META_REPO}/git/refs`, "POST", { + ref: `refs/heads/${branchName}`, + sha: headSha, + }); + + // Push each file to the branch + for (const { path: filePath, content } of changedFiles) { + let fileSha: string | undefined; + try { + const fileData = await githubFetch( + `/repos/${META_OWNER}/${META_REPO}/contents/${filePath}`, + ); + fileSha = fileData.sha; + } catch { + // File does not exist on remote yet (unlikely for these files) + } + + const encoded = Buffer.from(content, "utf-8").toString("base64"); + const body: Record = { + message: `chore: sync registry artifacts [skip ci]`, + content: encoded, + branch: branchName, + }; + if (fileSha) body.sha = fileSha; + + await githubWrite( + `/repos/${META_OWNER}/${META_REPO}/contents/${filePath}`, + "PUT", + body, + ); + } + + const editSummary = + editDiff.length > 0 + ? editDiff.map((d) => `${d.slug}.${d.field}: ${JSON.stringify(d.from)} -> ${JSON.stringify(d.to)}`).join(", ") + : "no field edits (pure resync)"; + + await openAndMergeMetaPR( + branchName, + `chore: sync registry artifacts${edit ? ` (${Object.keys(edit).join(", ")})` : ""} [skip ci]`, + `Registry sync via devtools_syncRegistry.\n\nChanges: ${editSummary}`, + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + apply: true, + editDiff, + filesUpdated: changedFiles.map((f) => f.path), + status: "merged", + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return errorResponse(error); + } + }, + ); +} diff --git a/src/utils/github.ts b/src/utils/github.ts index c35b15c..4d07130 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -127,7 +127,43 @@ export async function fetchRegistry(): Promise { return JSON.parse(raw) as RegistryEntry[]; } -export async function fetchMetaVersion(): Promise { - const raw = await rawFetch(META_OWNER, META_REPO, "VERSION"); +export async function fetchStandardsVersion(): Promise { + const raw = await rawFetch(META_OWNER, META_REPO, "STANDARDS_VERSION"); return raw.trim(); } + +export async function githubWrite( + path: string, + method: "POST" | "PUT" | "PATCH" | "DELETE", + body?: unknown, +): Promise { + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; + if (!token) { + throw new GitHubError( + "GH_TOKEN or GITHUB_TOKEN is required for write operations", + 401, + path, + ); + } + + const url = `https://api.github.com${path}`; + const opts: RequestInit = { + method, + headers: { ...buildHeaders(), "Content-Type": "application/json" }, + }; + if (body !== undefined) opts.body = JSON.stringify(body); + + const res = await fetch(url, opts); + if (res.status === 404) throw new NotFoundError(path); + if (res.status === 403 || res.status === 429) throw new RateLimitError(); + if (res.status === 204) return {} as T; + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new GitHubError( + `GitHub ${method} ${res.status} for ${path}: ${errText}`, + res.status, + path, + ); + } + return res.json() as Promise; +}