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
5 changes: 5 additions & 0 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ plugin de Claude Code para que un agente lo instale y lo maneje por vos:
Agrega un skill `becwright` y un comando `/becwright`. Ver
[`integrations/claude-code/`](integrations/claude-code/).

Para resultados estructurados, `becwright check --json` imprime un resumen
legible por máquina, y `becwright mcp` (instalá el extra `mcp`: `pipx install
"becwright[mcp]"`) levanta un servidor MCP que expone `check` y `list_checks` a
cualquier agente. Ver [`documentation/mcp.md`](documentation/mcp.md).

Una regla en `.bec/rules.yaml`:

```yaml
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ Claude Code plugin so an agent can install and drive it for you:
It adds a `becwright` skill and a `/becwright` command. See
[`integrations/claude-code/`](integrations/claude-code/).

For structured results, `becwright check --json` prints a machine-readable
summary, and `becwright mcp` (install the `mcp` extra: `pipx install
"becwright[mcp]"`) runs an MCP server exposing `check` and `list_checks` to any
agent. See [`documentation/mcp.md`](documentation/mcp.md).

A rule in `.bec/rules.yaml`:

```yaml
Expand Down
68 changes: 68 additions & 0 deletions documentation/mcp.es.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Servidor MCP y salida JSON

Para agentes de IA, becwright expone sus resultados en formato legible por
máquina de dos maneras.

## `becwright check --json`

Igual que `becwright check`, pero imprime un resumen JSON en vez de texto con
color, consumible sin parsear. El código de salida no cambia (1 si falló una
regla blocking, si no 0).

```json
{
"rule_count": 3,
"checked_files": 1,
"blocked": true,
"results": [
{
"id": "no-debugger-js",
"severity": "blocking",
"passed": false,
"intent": "Do not leave 'debugger;' in JavaScript/TypeScript code.",
"why_it_matters": "A forgotten 'debugger' halts execution ...",
"output": "app.js:1\n > function f(){ debugger; }"
}
]
}
```

No necesita dependencias extra y funciona también desde el binario autónomo.

## Servidor MCP

`becwright mcp` levanta un servidor [Model Context
Protocol](https://modelcontextprotocol.io) sobre stdio, así cualquier agente
compatible con MCP (Claude, Cursor, Windsurf, …) obtiene becwright como tools
estructuradas.

Requiere el extra opcional `mcp`:

```bash
pipx install "becwright[mcp]" # o: pip install "becwright[mcp]"
```

### Tools

| Tool | Argumentos | Devuelve |
|---|---|---|
| `check` | `all_files` (bool), `path` (dir del repo, opcional) | el mismo resumen que `check --json` |
| `list_checks` | — | los checks built-in como `{name, description}` |

### Configuración del cliente

Apuntá la config MCP de tu agente al comando:

```json
{
"mcpServers": {
"becwright": {
"command": "becwright",
"args": ["mcp"]
}
}
}
```

El servidor MCP viaja solo con el paquete de Python (no está en el binario de
npm). Para uso de CLI/hook sin Python, seguí usando la instalación por npm.
66 changes: 66 additions & 0 deletions documentation/mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# MCP server & JSON output

For AI agents, becwright exposes its results in machine-readable form two ways.

## `becwright check --json`

Same as `becwright check`, but prints a JSON summary instead of colored text and
is consumable without parsing. Exit code is unchanged (1 if a blocking rule
failed, else 0).

```json
{
"rule_count": 3,
"checked_files": 1,
"blocked": true,
"results": [
{
"id": "no-debugger-js",
"severity": "blocking",
"passed": false,
"intent": "Do not leave 'debugger;' in JavaScript/TypeScript code.",
"why_it_matters": "A forgotten 'debugger' halts execution ...",
"output": "app.js:1\n > function f(){ debugger; }"
}
]
}
```

This needs no extra dependency and works from the standalone binary too.

## MCP server

`becwright mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io)
server over stdio, so any MCP-capable agent (Claude, Cursor, Windsurf, …) gets
becwright as structured tools.

It requires the optional `mcp` extra:

```bash
pipx install "becwright[mcp]" # or: pip install "becwright[mcp]"
```

### Tools

| Tool | Arguments | Returns |
|---|---|---|
| `check` | `all_files` (bool), `path` (optional repo dir) | the same summary as `check --json` |
| `list_checks` | — | the built-in checks as `{name, description}` |

### Client configuration

Point your agent's MCP config at the command:

```json
{
"mcpServers": {
"becwright": {
"command": "becwright",
"args": ["mcp"]
}
}
}
```

The MCP server ships only with the Python package (it is not in the npm binary).
For CLI/hook usage without Python, keep using the npm install.
4 changes: 4 additions & 0 deletions integrations/claude-code/skills/becwright/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ commit runs the checks; a blocking rule that fails stops the commit.
| `becwright import <url-or-file>` | Add a BEC from the catalog (shows code, asks before installing) |
| `becwright export <rule-id>` | Export a rule as a portable `.bec.yaml` |

For structured results you can parse, use `becwright check --json`. An MCP server
is also available (`becwright mcp`, needs the `mcp` extra) exposing `check` and
`list_checks` as tools.

Catalog of ready-to-use BECs:
https://github.com/DataDave-Dev/becwright/tree/main/becs

Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ classifiers = [
becwright = "becwright.cli:main"

[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov>=5"]
dev = ["pytest>=8", "pytest-cov>=5", "mcp>=1.2"]
# Used to freeze the standalone binary distributed via npm (see packaging/).
build = ["pyinstaller>=6"]
# MCP server for AI agents: `becwright mcp` (see src/becwright/mcp_server.py).
mcp = ["mcp>=1.2"]

[tool.setuptools.packages.find]
where = ["src"]
Expand Down
29 changes: 23 additions & 6 deletions src/becwright/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import urllib.request
from pathlib import Path

from . import __version__, bundle, git
from .engine import Result, evaluate
from . import __version__, bundle, git, report
from .engine import Result
from .rules import load_rules

RED = "\033[91m"; GREEN = "\033[92m"; YELLOW = "\033[93m"
Expand Down Expand Up @@ -37,18 +37,21 @@ def _print_result(result: Result) -> None:

def _cmd_check(args: argparse.Namespace) -> int:
root = git.repo_root()
rules = load_rules(root / ".bec" / "rules.yaml")
rules, files, result = report.gather(root, all_files=args.all)

if args.json:
import json
print(json.dumps(report.payload(rules, files, result), indent=2))
return 1 if (result and result.had_blocking) else 0

if not rules:
print(f"{YELLOW}No .bec/rules.yaml with rules. Nothing to check.{RESET}")
return 0

files = git.files_to_check(root, all_files=args.all)
if not files:
print(f"{DIM}No files to check.{RESET}")
return 0

print(f"{BOLD}BEC -- {len(files)} file(s) against {len(rules)} rule(s){RESET}\n")
result = evaluate(rules, files, root)
_print_result(result)

if result.had_blocking:
Expand Down Expand Up @@ -98,6 +101,18 @@ def _cmd_run(args: argparse.Namespace) -> int:
return import_module(f"becwright.checks.{args.module}").main()


def _cmd_mcp(_: argparse.Namespace) -> int:
try:
from .mcp_server import serve
except ImportError:
print(f"{RED}The MCP server needs the 'mcp' extra.{RESET}", file=sys.stderr)
print(f'{DIM} pipx install "becwright[mcp]" (or: pip install "becwright[mcp]"){RESET}',
file=sys.stderr)
return 2
serve()
return 0


def _cmd_list(_: argparse.Namespace) -> int:
print(f"{BOLD}Built-in checks{RESET} {DIM}(use as: becwright run <name>){RESET}")
for name in _builtin_check_names():
Expand Down Expand Up @@ -283,6 +298,7 @@ def _build_parser() -> argparse.ArgumentParser:

p_check = sub.add_parser("check", help="check the code against the rules")
p_check.add_argument("--all", action="store_true", help="check the whole repo, not just staging")
p_check.add_argument("--json", action="store_true", help="output results as JSON")
p_check.set_defaults(func=_cmd_check)

p_init = sub.add_parser("init", help="scaffold a starter .bec/rules.yaml and install the hook")
Expand All @@ -295,6 +311,7 @@ def _build_parser() -> argparse.ArgumentParser:
p_run.set_defaults(func=_cmd_run)

sub.add_parser("list", help="list the built-in checks").set_defaults(func=_cmd_list)
sub.add_parser("mcp", help="run the MCP server for AI agents (needs the 'mcp' extra)").set_defaults(func=_cmd_mcp)
sub.add_parser("install", help="install the pre-commit hook").set_defaults(func=_cmd_install)
sub.add_parser("uninstall", help="remove the pre-commit hook").set_defaults(func=_cmd_uninstall)

Expand Down
4 changes: 2 additions & 2 deletions src/becwright/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ class NotAGitRepo(RuntimeError):
pass


def repo_root() -> Path:
def repo_root(cwd: Path | None = None) -> Path:
res = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True,
cwd=cwd, capture_output=True, text=True,
)
if res.returncode != 0:
raise NotAGitRepo("You are not inside a git repository.")
Expand Down
45 changes: 45 additions & 0 deletions src/becwright/mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""MCP server exposing becwright to AI agents as structured tools.

Only imported when running `becwright mcp`, so the `mcp` dependency stays optional
(install the `mcp` extra). The tools are thin wrappers over the same logic the CLI
uses, returning JSON-serializable results instead of human text.
"""
from __future__ import annotations

from pathlib import Path

from mcp.server.fastmcp import FastMCP

from . import git, report
from .cli import _CHECK_DESCRIPTIONS, _builtin_check_names

mcp = FastMCP("becwright")


@mcp.tool()
def check(all_files: bool = False, path: str | None = None) -> dict:
"""Run becwright's rules and return structured results.

Args:
all_files: check the whole repo instead of only staged files.
path: a directory inside the target git repo (defaults to the cwd).

Returns a summary with rule_count, checked_files, a `blocked` flag and a
per-rule list of {id, severity, passed, intent, why_it_matters, output}.
"""
root = git.repo_root(Path(path) if path else None)
rules, files, result = report.gather(root, all_files=all_files)
return report.payload(rules, files, result)


@mcp.tool()
def list_checks() -> list[dict]:
"""List becwright's built-in checks as {name, description}."""
return [
{"name": name, "description": _CHECK_DESCRIPTIONS.get(name, "")}
for name in _builtin_check_names()
]


def serve() -> None:
mcp.run()
38 changes: 38 additions & 0 deletions src/becwright/report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

from pathlib import Path

from . import git
from .engine import Result, evaluate
from .rules import Rule, load_rules


def gather(root: Path, *, all_files: bool) -> tuple[list[Rule], list[str], Result | None]:
"""Load rules, find the files to check and evaluate them. The result is None
when there is nothing to check (no rules or no files)."""
rules = load_rules(root / ".bec" / "rules.yaml")
files = git.files_to_check(root, all_files=all_files)
if not rules or not files:
return rules, files, None
return rules, files, evaluate(rules, files, root)


def payload(rules: list[Rule], files: list[str], result: Result | None) -> dict:
"""Build a JSON-serializable summary shared by `check --json` and the MCP server."""
results = []
if result is not None:
for r in result.per_rule:
results.append({
"id": r.rule.id,
"severity": r.rule.severity,
"passed": r.passed,
"intent": r.rule.intent or None,
"why_it_matters": r.rule.why_it_matters or None,
"output": r.output or None,
})
return {
"rule_count": len(rules),
"checked_files": len(files),
"blocked": result.had_blocking if result else False,
"results": results,
}
8 changes: 8 additions & 0 deletions tests/test_cli_and_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,11 @@ def test_run_unknown_check_returns_2(monkeypatch):
import io
monkeypatch.setattr("sys.stdin", io.StringIO(""))
assert cli.main(["run", "nope_check"]) == 2


# --- the mcp subcommand ---

def test_mcp_subcommand_without_extra(monkeypatch):
# Simulate the 'mcp' extra not being installed: force the import to fail.
monkeypatch.setitem(sys.modules, "becwright.mcp_server", None)
assert cli.main(["mcp"]) == 2
Loading
Loading