Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
154 changes: 154 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 <name>` 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 <name>`, not `m-cli-extras <name>`."
- "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 <name>` 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 <name>`; 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/<name>/` with its own `register()` in
`cli.py` and its own test module at `tests/test_<name>.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).
1 change: 1 addition & 0 deletions CLAUDE.md
39 changes: 38 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 ✓"
10 changes: 10 additions & 0 deletions dist/plugins.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"plugins": [
{
"module": "m_cli_extras.corpus_stats.cli:register",
"name": "corpus-stats",
"registered_by": "m_cli.plugins"
}
],
"schema_version": "1"
}
17 changes: 17 additions & 0 deletions dist/repo.meta.json
Original file line number Diff line number Diff line change
@@ -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"
}
108 changes: 108 additions & 0 deletions tools/check-manifest.py
Original file line number Diff line number Diff line change
@@ -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 "<root>"
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())
Loading
Loading