From 8db54da657c368dbafad962afd1d9936e68be2b9 Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Sun, 10 May 2026 23:00:34 -0400 Subject: [PATCH] phase2-onboarding: adopt Phase 0 contract for m-cli-extras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboards m-cli-extras as a Tier-2 entry to the org-level AI-discoverability catalog. Mirrors the contract shape used by m-modern-corpus (generator-based exposed payload) and m-test-engine (authored-from-scratch AGENTS.md + check-manifest.py drift gate). Adds: - AGENTS.md (+ CLAUDE.md symlink): YAML descriptor (kind=plugin-host, exposes.plugins, companions.m-cli=consumer-of-this-plugin-group) plus the five required Phase-0 sections (Setup / Test / Build / Verify / Guardrails). Calls out the not-a-CLI semantics, the don't-hand-edit-dist/plugins.json gate, the new-plugin-as-own-subdir convention, and m-stdlib's architectural priority. - tools/gen-plugins.py: stdlib-only deterministic generator (tomllib). Reads pyproject.toml's [project.entry-points."m_cli.plugins"] table and emits dist/plugins.json with schema_version="1", sorted by plugin name, 2-space indent, trailing newline. Byte-identical on re-run against an unchanged pyproject.toml (verified locally). - tools/check-manifest.py: copied verbatim from tree-sitter-m. Validates dist/repo.meta.json shape + that exposes.* paths resolve on disk. - dist/plugins.json: today's output — the corpus-stats plugin. - dist/repo.meta.json: id=tool:m-cli-extras, role=out-of-tree m-cli plugins, exposes.plugins + exposes.pyproject_toml, consumes=[tool:m-cli], verification_commands=[make manifest, make check-manifest, make test]. Extends: - Makefile: three new .PHONY targets — manifest, check-manifest, check-docs-prose. Existing dev targets (test/lint/format/mypy/cov/ check/clean) untouched. - .github/workflows/ci.yml: two engine-free steps inserted BEFORE the sibling-clone / uv-sync / test steps so a broken manifest fails fast without paying the install cost. - .gitignore: dist/ → dist/* with !dist/repo.meta.json and !dist/plugins.json exceptions. Same fix tree-sitter-m's onboarding needed (see its commit ade59d6); without this, the Python-default dist/ ignore silently drops the Phase-0 contract artifacts. Local verification (all green): - make manifest twice → byte-identical dist/plugins.json (md5 match) - make check-manifest → 0 - make check-docs-prose → no docs/ ✓ - canonical Track-A validator (.github/profile/build/validate-repo-meta.py) → OK: dist/repo.meta.json DON'Ts honored: no changes to .github/ (org-side update is a separate coordinated follow-up); no new runtime deps (tools/gen-plugins.py is stdlib-only, tomllib lands in Python 3.11+). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 14 ++++ .gitignore | 6 +- AGENTS.md | 154 +++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + Makefile | 39 +++++++++- dist/plugins.json | 10 +++ dist/repo.meta.json | 17 +++++ tools/check-manifest.py | 108 +++++++++++++++++++++++++++ tools/gen-plugins.py | 113 ++++++++++++++++++++++++++++ 9 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 dist/plugins.json create mode 100644 dist/repo.meta.json create mode 100755 tools/check-manifest.py create mode 100755 tools/gen-plugins.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7776c10..6e8844b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,20 @@ jobs: with: python-version: "3.12" + - name: Manifest drift gate + # Engine-free Phase-0 gate. Regenerates dist/plugins.json from + # pyproject.toml's entry-point table and asserts no diff, then + # validates dist/repo.meta.json shape + that exposes.* paths + # resolve on disk. Runs BEFORE tests so a broken manifest fails + # fast (no need to spin up the uv install + sibling clones). + run: make check-manifest + + - name: docs/ prose-only gate + # Cross-repo guardrail: docs/ holds only human-readable prose. + # This repo has no docs/ today; the gate is trivially green + # unless someone adds a non-prose file under a future docs/. + run: make check-docs-prose + - name: Clone tree-sitter-m as sibling (m-cli's transitive dep) # m-cli's pyproject.toml URL-pins tree-sitter-m wheels for # released versions, but for a path-dep install of m-cli (the diff --git a/.gitignore b/.gitignore index b310c2c..577de98 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,11 @@ __pycache__/ *.egg .eggs/ build/ -dist/ +dist/* +# Phase-0 contract artifacts: org catalog fetches these by raw URL; +# track them despite the broader dist/ ignore above. +!dist/repo.meta.json +!dist/plugins.json # uv / venv .venv/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a089d8d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,154 @@ +--- +# Machine-readable project descriptor. +name: m-cli-extras +kind: [tool, plugin-host] +status: active +languages: [python] + +distribution: + pypi: null # not yet on PyPI + github: m-dev-tools/m-cli-extras + +location: ~/m-dev-tools/m-cli-extras + +exposes: + plugins: "dist/plugins.json" # generated from pyproject's entry-point table + pyproject_toml: "pyproject.toml" # the canonical entry-point declaration + entry_point_group: "m_cli.plugins" # what m-cli walks at startup + +consumes: + formats: [] + services: [] + +companions: + - project: m-cli + relation: "consumer of this plugin group — m-cli walks the `m_cli.plugins` entry-point group at startup and registers each plugin's `register(subparsers)` as an `m ` subcommand" + - project: tree-sitter-m + relation: "indirect runtime dep — the `corpus-stats` plugin imports `m_cli.parse` which uses tree-sitter-m for label counting" + +incompatibilities: + - "Not a CLI. m-cli-extras is a plugin host package; the entry point is `m `, not `m-cli-extras `." + - "Not a vendor of m-cli core subcommands. `fmt` / `lint` / `test` / `coverage` / `doc` belong to m-cli core — don't reimplement them here." + - "Not a substitute for m-stdlib. Plugins that need M-side primitives should call STD* routines, not bundle their own." + +docs: + primary: README.md +--- + +# m-cli-extras — Claude project context + +Python plugin host for m-cli. Each plugin registers via the +`m_cli.plugins` entry-point group; m-cli walks the group at startup +and exposes every registered plugin as an `m ` subcommand. + +## What this is + +- A Python package (`m_cli_extras`) shipping out-of-tree m-cli + subcommands. +- An entry-point table in `pyproject.toml` under + `[project.entry-points."m_cli.plugins"]` — one line per plugin, + pointing at a `register(subparsers)` callable. +- A `dist/plugins.json` enumerating the registered plugins for the + org-level AI-discoverability catalog (deterministic regen from + `pyproject.toml`). +- Today's shipped plugin: `corpus-stats` — walks a directory of `.m` + files and reports file / line / label / parse-error counts. + +## What this is NOT + +- A CLI of its own. The user-facing entry point is `m `; m-cli + is the only CLI in this corner of the ecosystem. +- A vendor of m-cli core subcommands. The canonical five + (`fmt`/`lint`/`test`/`coverage`/`doc`) live in m-cli and stay + there; m-cli-extras is the bucket for niche / opinionated / + third-party-flavored utilities that shouldn't bloat core. +- A standalone runtime. m-cli-extras is useless without m-cli + installed (it depends on `m_cli.parse`, `argparse` wiring, the + plugin discovery walk). + +## Setup + +```bash +git clone https://github.com/m-dev-tools/m-cli-extras +cd m-cli-extras +uv sync --extra dev +``` + +`pyproject.toml` declares m-cli as a sibling path-dep +(`tool.uv.sources.m-cli = { path = "../m-cli", editable = true }`), +so a sibling checkout of m-cli is required for dev installs. The +CI workflow clones it alongside this repo before `uv sync`. + +## Test + +```bash +make test # pytest, exits 0 on green +make check # ruff + mypy + pytest --cov (full pre-commit gate) +``` + +The test suite drives the plugin's `register(subparsers)` callable +against a fresh `argparse.ArgumentParser` per test — no install +required, no m-cli runtime needed at test time beyond the import +boundary. + +## Build / generate + +```bash +make manifest # regenerate dist/plugins.json from pyproject.toml +``` + +`tools/gen-plugins.py` reads `[project.entry-points."m_cli.plugins"]` +out of `pyproject.toml` and emits a deterministic +`dist/plugins.json` listing each registered plugin. Re-running on an +unchanged `pyproject.toml` must produce byte-identical output; CI's +`make check-manifest` gates on this. + +When you add a new plugin to the entry-point table, run `make +manifest` and commit the regenerated `dist/plugins.json` in the same +change. The CI drift gate will reject a PR that updates +`pyproject.toml` without the matching `dist/` regen. + +## Verify + +The `verification_commands` declared in `dist/repo.meta.json`: + +```bash +make manifest # regenerate +make check-manifest # drift gate: regen + git diff dist/ + schema check +make test # pytest +``` + +Plus the cross-repo guardrail: + +```bash +make check-docs-prose # docs/ holds only prose (this repo has no docs/ today) +``` + +## Guardrails + +- **Do not hand-edit `dist/plugins.json`.** It is a pipeline output of + `tools/gen-plugins.py` over `pyproject.toml`'s entry-point table. + The CI drift gate will reject any direct edit. Add plugins by + editing `[project.entry-points."m_cli.plugins"]`, then `make + manifest`. +- **New plugins ship as their own subdirectory / module.** Each plugin + lives at `src/m_cli_extras//` with its own `register()` in + `cli.py` and its own test module at `tests/test_.py`. Don't + cross-import between plugins — they're meant to be independently + extractable. +- **m-stdlib has architectural priority over m-cli-extras.** If a + plugin needs an M-side primitive (JSON, dates, regex, assertion + plumbing), propose adding it to `m-stdlib` first. Don't bundle ad + hoc M code under `src/` to work around a missing stdlib module. +- **Don't reimplement m-cli core.** `fmt`/`lint`/`test`/`coverage`/ + `doc` are core subcommands and belong to m-cli. m-cli-extras is for + utilities that don't fit the core five and that wouldn't pay rent + in every install. +- **The entry-point group name is contract.** `m_cli.plugins` is what + m-cli walks at startup. Renaming it is a breaking change requiring + a coordinated update in m-cli (the consumer) and in any third-party + plugin packages. +- **Do not hand-edit `dist/repo.meta.json` `verified_on` to a future + date.** The org smoke test rejects manifests older than 90 days; + bump the date only when the manifest changes materially (new + exposed payload, role change, license change). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Makefile b/Makefile index 8603498..60f2c5b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test lint format mypy cov check clean +.PHONY: install test lint format mypy cov check clean manifest check-manifest check-docs-prose PYTHON := .venv/bin/python PYTEST := .venv/bin/pytest @@ -27,3 +27,40 @@ check: lint mypy cov clean: rm -rf .pytest_cache .ruff_cache .mypy_cache + +# ── Phase-0 AI-discoverability contract ─────────────────────────────── +# +# Tier-2 entry to the org catalog. See +# https://github.com/m-dev-tools/.github/blob/main/docs/AI-discoverability-plan.md +# +# `dist/plugins.json` is regenerated from pyproject.toml's +# `[project.entry-points."m_cli.plugins"]` table by tools/gen-plugins.py. +# Engine-free, stdlib-only — no uv / no venv required. + +manifest: + python3 tools/gen-plugins.py + +# Phase-0 drift gate: regenerate, then assert the working tree is clean +# and that dist/repo.meta.json is well-formed with all exposes.* paths +# resolving on disk. Same shape as m-modern-corpus / m-stdlib / m-cli. +check-manifest: manifest + @git diff --exit-code dist/ \ + || { echo "ERROR: dist/ drift — run 'make manifest' and commit."; exit 1; } + python3 tools/check-manifest.py + +# Guardrail: docs/ holds only human-readable prose. Non-prose artifacts +# (data, output, metadata, examples) belong elsewhere. Same target name +# as the tier-1 repos so a contributor finds it predictable. +check-docs-prose: + @if [ ! -d docs ]; then echo "check-docs-prose: no docs/ directory ✓"; exit 0; fi; \ + violations=$$(find docs -type f \ + ! -name '*.md' ! -name '*.markdown' \ + ! -name '*.png' ! -name '*.jpg' ! -name '*.jpeg' \ + ! -name '*.gif' ! -name '*.svg' ! -name '*.webp' \ + ! -name '.gitkeep'); \ + if [ -n "$$violations" ]; then \ + echo "ERROR: non-prose files under docs/ — move to a top-level domain dir:" >&2; \ + echo "$$violations" >&2; \ + exit 1; \ + fi; \ + echo "check-docs-prose: docs/ is prose-only ✓" diff --git a/dist/plugins.json b/dist/plugins.json new file mode 100644 index 0000000..6ace557 --- /dev/null +++ b/dist/plugins.json @@ -0,0 +1,10 @@ +{ + "plugins": [ + { + "module": "m_cli_extras.corpus_stats.cli:register", + "name": "corpus-stats", + "registered_by": "m_cli.plugins" + } + ], + "schema_version": "1" +} diff --git a/dist/repo.meta.json b/dist/repo.meta.json new file mode 100644 index 0000000..51df99c --- /dev/null +++ b/dist/repo.meta.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/m-dev-tools/.github/main/profile/repo.meta.schema.json", + "id": "tool:m-cli-extras", + "repo": "https://github.com/m-dev-tools/m-cli-extras", + "role": "Out-of-tree m-cli plugins — niche subcommands registered via the m_cli.plugins entry-point group", + "language": ["python"], + "license": "AGPL-3.0", + "agent_instructions": "AGENTS.md", + "verified_on": "2026-05-10", + "exposes": { + "plugins": "dist/plugins.json", + "pyproject_toml": "pyproject.toml" + }, + "consumes": ["tool:m-cli"], + "verification_commands": ["make manifest", "make check-manifest", "make test"], + "status": "active" +} diff --git a/tools/check-manifest.py b/tools/check-manifest.py new file mode 100755 index 0000000..f01d59a --- /dev/null +++ b/tools/check-manifest.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Phase-0 contract gate for dist/repo.meta.json. + +Validates that: + 1. dist/repo.meta.json parses as JSON. + 2. Required fields from the org-level repo.meta.schema.json contract + are present. + 3. Each path under `exposes.*` resolves on disk. + 4. (Best-effort) full schema validation if jsonschema is available + and the canonical schema URL is reachable. + +Exits 0 on success; non-zero with structured stderr on failure. + +Engine-free, no Node, no Python deps beyond the standard library +unless jsonschema happens to be installed. +""" + +from __future__ import annotations + +import json +import sys +import urllib.request +from pathlib import Path + +MANIFEST = Path("dist/repo.meta.json") + +REQUIRED_FIELDS = ( + "id", + "repo", + "role", + "language", + "license", + "agent_instructions", + "verified_on", + "exposes", + "verification_commands", +) + + +def main() -> int: + if not MANIFEST.exists(): + print(f"ERROR: {MANIFEST} not found", file=sys.stderr) + return 1 + + try: + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"ERROR: {MANIFEST} is invalid JSON: {exc}", file=sys.stderr) + return 1 + + missing = [f for f in REQUIRED_FIELDS if f not in data] + if missing: + print(f"ERROR: missing required fields: {missing}", file=sys.stderr) + return 1 + + fail = False + for key, rel_path in data["exposes"].items(): + if rel_path.startswith(("http://", "https://")): + continue + if not Path(rel_path).exists(): + print( + f"ERROR: exposes.{key} payload missing on disk: {rel_path}", + file=sys.stderr, + ) + fail = True + if fail: + return 1 + + # Best-effort full schema validation. Skipped silently if jsonschema + # isn't available (the canonical Track-A validator runs in the org + # smoke test against the same manifest). + try: + from jsonschema import Draft202012Validator # type: ignore + except ImportError: + print( + "check-manifest: dist/repo.meta.json valid; " + "all exposes.* present ✓ (jsonschema not installed — " + "skipping full schema validation)" + ) + return 0 + + schema_uri = data.get("$schema", "") + try: + with urllib.request.urlopen(schema_uri, timeout=5) as resp: + schema = json.load(resp) + except Exception as exc: # noqa: BLE001 + print( + f"check-manifest: dist/repo.meta.json valid; all exposes.* " + f"present ✓ (skipped live schema fetch: {exc})" + ) + return 0 + + errors = list(Draft202012Validator(schema).iter_errors(data)) + if errors: + for err in errors: + path = "/".join(str(p) for p in err.absolute_path) or "" + print(f"SCHEMA ERROR at {path}: {err.message}", file=sys.stderr) + return 1 + + print( + "check-manifest: dist/repo.meta.json valid against org schema; " + "all exposes.* present ✓" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gen-plugins.py b/tools/gen-plugins.py new file mode 100755 index 0000000..2ea00f7 --- /dev/null +++ b/tools/gen-plugins.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Generate dist/plugins.json from pyproject.toml's entry-point table. + +Inputs: + - pyproject.toml's `[project.entry-points."m_cli.plugins"]` table. + Each `name = "module:callable"` line registers one plugin that + m-cli walks at startup (see m-cli/docs/plugin-development.md). + +Output: + - dist/plugins.json — a deterministic enumeration of those plugins + for the org-level AI-discoverability catalog. Schema: + + { + "schema_version": "1", + "plugins": [ + { + "name": "", + "module": "", + "registered_by": "m_cli.plugins" + }, + ... + ] + } + +Determinism: + - plugins sorted by `name`, 2-space indent, sorted JSON keys, + trailing newline. Running twice on an unchanged pyproject.toml + must produce byte-identical output. CI's `make check-manifest` + gates on this. + +Engine-free: stdlib only (uses `tomllib`, Python 3.11+). +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from pathlib import Path + +SCHEMA_VERSION = "1" +ENTRY_POINT_GROUP = "m_cli.plugins" + + +def load_plugins(pyproject_path: Path) -> list[dict[str, str]]: + """Extract the m_cli.plugins entry-point table from pyproject.toml.""" + with pyproject_path.open("rb") as f: + data = tomllib.load(f) + + entry_points = ( + data.get("project", {}).get("entry-points", {}).get(ENTRY_POINT_GROUP, {}) + ) + + plugins = [ + { + "name": name, + "module": module, + "registered_by": ENTRY_POINT_GROUP, + } + for name, module in sorted(entry_points.items()) + ] + return plugins + + +def build_payload(plugins: list[dict[str, str]]) -> dict: + """Wrap the plugin list in the schema-versioned envelope.""" + payload: dict = { + "schema_version": SCHEMA_VERSION, + "plugins": plugins, + } + if not plugins: + payload["_comment"] = ( + f"No entries under [project.entry-points.\"{ENTRY_POINT_GROUP}\"] " + "in pyproject.toml." + ) + return payload + + +def write_json(path: Path, payload: dict) -> None: + """Deterministic JSON write — sorted keys, 2-space indent, trailing newline.""" + path.parent.mkdir(parents=True, exist_ok=True) + body = json.dumps(payload, indent=2, sort_keys=True) + "\n" + path.write_text(body, encoding="utf-8") + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="Repo root (defaults to the parent of tools/).", + ) + args = parser.parse_args(argv[1:]) + + pyproject_path = args.root / "pyproject.toml" + if not pyproject_path.exists(): + print(f"ERROR: {pyproject_path} not found", file=sys.stderr) + return 1 + + plugins = load_plugins(pyproject_path) + payload = build_payload(plugins) + + out = args.root / "dist" / "plugins.json" + write_json(out, payload) + + print(f"wrote {out.relative_to(args.root)} ({len(plugins)} plugin(s))") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))