diff --git a/README.md b/README.md index 1ee2518..30bd90c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,32 @@ pip install fixyourdocs Requires Python 3.9+. +## CLI + +The package ships a `fixyourdocs` console script covering the two +one-liners from the [agents-md-snippet](https://github.com/fixyourdocs/agents-md-snippet) +README: + +```sh +# Adds the canonical AGENTS.md block to your repo. Idempotent. +pipx run fixyourdocs init + +# Sends a single report to the Hub. +pipx run fixyourdocs report \ + --doc-url https://example.com/docs/install \ + --summary "Install fails on macOS 14" \ + --agent claude-code \ + --kind broken +``` + +`init` auto-detects `AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, or +`.github/copilot-instructions.md` and appends to whichever exists +(falling back to creating `AGENTS.md`). Pass `--file ` to override. + +`report` accepts `--details`, `--suggested-fix`, `--api-url`, `--token`, +and `--json` for machine-readable output. Exit codes: `0` success, +`2` user error, `1` transport or server error. + ## Usage (sync) ```python diff --git a/pyproject.toml b/pyproject.toml index 06ec6e4..bad48de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,9 @@ Homepage = "https://docsfeedback.org" Repository = "https://github.com/fixyourdocs/sdk-python" Specification = "https://github.com/fixyourdocs/protocol" +[project.scripts] +fixyourdocs = "fixyourdocs._cli:main" + [tool.hatch.build.targets.wheel] packages = ["src/fixyourdocs"] diff --git a/src/fixyourdocs/_cli.py b/src/fixyourdocs/_cli.py new file mode 100644 index 0000000..6ea709a --- /dev/null +++ b/src/fixyourdocs/_cli.py @@ -0,0 +1,193 @@ +"""``fixyourdocs`` CLI — ``init`` + ``report`` subcommands. + +Exit codes: + +* ``0`` — success. +* ``2`` — user / argument error (unknown / missing flag, bad enum). +* ``1`` — transport or server error. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Sequence +from pathlib import Path +from typing import IO, Optional + +from fixyourdocs import __version__ +from fixyourdocs._init import run_init +from fixyourdocs.client import Client +from fixyourdocs.errors import FixYourDocsError +from fixyourdocs.models import Report, ReportKind + +DEFAULT_API_URL = "https://hub.fixyourdocs.io" +_REPORT_KINDS = tuple(k.value for k in ReportKind) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="fixyourdocs", + description=( + "FixYourDocs CLI — adds the canonical AGENTS.md block to your " + "repo and sends Docs Feedback Protocol v0 reports." + ), + ) + parser.add_argument( + "-v", "--version", action="version", version=__version__ + ) + sub = parser.add_subparsers(dest="command", metavar="") + + init_p = sub.add_parser( + "init", + help="Append the canonical AGENTS.md block to your repo.", + ) + init_p.add_argument( + "--file", + help=( + "Explicit target file (skips auto-detection of AGENTS.md / " + "CLAUDE.md / .cursor/rules / .github/copilot-instructions.md)." + ), + ) + init_p.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON instead of plain text.", + ) + + rep = sub.add_parser( + "report", + help="Send a Docs Feedback Protocol v0 report to the Hub.", + ) + rep.add_argument("--doc-url", required=True) + rep.add_argument("--summary", required=True) + rep.add_argument("--agent", required=True, dest="agent_name") + rep.add_argument( + "--kind", + choices=_REPORT_KINDS, + default="other", + ) + rep.add_argument("--details") + rep.add_argument("--suggested-fix", dest="suggested_fix") + rep.add_argument("--api-url", default=DEFAULT_API_URL, dest="api_url") + rep.add_argument("--token") + rep.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON instead of plain text.", + ) + return parser + + +def _do_init(args: argparse.Namespace, stdout: IO[str]) -> int: + result = run_init(cwd=Path.cwd(), file=args.file) + if args.json: + stdout.write( + json.dumps( + { + "path": str(result.path), + "changed": result.changed, + "created": result.created, + } + ) + + "\n" + ) + elif result.created: + stdout.write(f"Created {result.path} with the FixYourDocs snippet.\n") + elif result.changed: + stdout.write( + f"Appended the FixYourDocs snippet to {result.path}.\n" + ) + else: + stdout.write( + f"No changes — snippet already present in {result.path}.\n" + ) + return 0 + + +def _do_report( + args: argparse.Namespace, stdout: IO[str], stderr: IO[str] +) -> int: + try: + report = Report.create( + doc_url=args.doc_url, + summary=args.summary, + kind=args.kind, + agent_name=args.agent_name, + details=args.details, + suggested_fix=args.suggested_fix, + ) + except (ValueError, FixYourDocsError) as err: + stderr.write(f"error: {err}\n") + return 2 + + api_url = args.api_url.rstrip("/") + with Client(api_url, token=args.token) as client: + try: + result = client.send(report) + except FixYourDocsError as err: + if args.json: + stdout.write( + json.dumps( + {"error": type(err).__name__, "message": str(err)} + ) + + "\n" + ) + else: + stderr.write(f"error: {err}\n") + return 1 + + if args.json: + stdout.write( + json.dumps( + { + "id": result.id, + "received_at": result.received_at.isoformat(), + "is_duplicate": result.is_duplicate, + "url": f"{api_url}/r/{result.id}", + } + ) + + "\n" + ) + else: + label = "Duplicate report" if result.is_duplicate else "Report accepted" + stdout.write(f"{label}: {result.id}\n") + stdout.write(f"View: {api_url}/r/{result.id}\n") + return 0 + + +def run_cli( + argv: Optional[Sequence[str]] = None, + *, + stdout: Optional[IO[str]] = None, + stderr: Optional[IO[str]] = None, +) -> int: + """Entry point used by both the console_script and the tests. + + Returns the desired process exit code; never calls ``sys.exit`` + itself. + """ + out = stdout if stdout is not None else sys.stdout + err = stderr if stderr is not None else sys.stderr + + parser = _build_parser() + # argparse calls sys.exit on errors; intercept to map to our exit codes. + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return 0 if exc.code == 0 else 2 + + if args.command is None: + parser.print_help(out) + return 0 + if args.command == "init": + return _do_init(args, out) + if args.command == "report": + return _do_report(args, out, err) + err.write(f"error: unknown command {args.command!r}\n") + return 2 + + +def main() -> None: # console_script entry point + sys.exit(run_cli(sys.argv[1:])) diff --git a/src/fixyourdocs/_init.py b/src/fixyourdocs/_init.py new file mode 100644 index 0000000..f627873 --- /dev/null +++ b/src/fixyourdocs/_init.py @@ -0,0 +1,61 @@ +"""``fixyourdocs init`` — append the canonical AGENTS.md block.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from fixyourdocs.snippet import SNIPPET, SNIPPET_HEADING + +TARGET_CANDIDATES = ( + "AGENTS.md", + "CLAUDE.md", + ".cursor/rules", + ".github/copilot-instructions.md", +) + + +@dataclass(frozen=True) +class InitResult: + path: Path + changed: bool + created: bool + + +def _pick_default_target(cwd: Path) -> str: + for candidate in TARGET_CANDIDATES: + if (cwd / candidate).exists(): + return candidate + return "AGENTS.md" + + +def _with_trailing_blank_line(s: str) -> str: + if not s: + return "" + if s.endswith("\n\n"): + return s + if s.endswith("\n"): + return s + "\n" + return s + "\n\n" + + +def run_init(cwd: Path, file: Optional[str] = None) -> InitResult: + """Append the canonical AGENTS.md block to the chosen target file. + + Idempotent: if the block heading is already present, returns + ``InitResult(changed=False)`` and leaves the file untouched. + """ + target_rel = file if file is not None else _pick_default_target(cwd) + path = (cwd / target_rel).resolve() + + if not path.exists(): + path.write_text(SNIPPET) + return InitResult(path=path, changed=True, created=True) + + current = path.read_text() + if SNIPPET_HEADING in current: + return InitResult(path=path, changed=False, created=False) + + path.write_text(_with_trailing_blank_line(current) + SNIPPET) + return InitResult(path=path, changed=True, created=False) diff --git a/src/fixyourdocs/snippet.py b/src/fixyourdocs/snippet.py new file mode 100644 index 0000000..47dd472 --- /dev/null +++ b/src/fixyourdocs/snippet.py @@ -0,0 +1,33 @@ +"""Canonical AGENTS.md block. + +Mirrored from the ``agents-md-snippet`` repo +(https://github.com/fixyourdocs/agents-md-snippet). The TypeScript SDK +keeps an identical copy in ``src/snippet.ts``; CI in both repos refuses +to merge a drift from the upstream code-fenced block. +""" + +from __future__ import annotations + +SNIPPET_HEADING = "## Documentation feedback" + +SNIPPET = """\ +## Documentation feedback + +If you find documentation in this repository that is broken, incorrect, +outdated, or incomplete while performing a task, file a structured report +via the Docs Feedback Protocol. This helps the maintainers fix the docs +for the next agent (or human) that hits the same issue. + +How to file a report: + + curl -X POST https://hub.fixyourdocs.io/v1/reports \\ + -H 'Content-Type: application/json' \\ + -d '{ + "doc_url": "", + "summary": "", + "details": "", + "context": "" + }' + +Spec: https://docsfeedback.org +""" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..51c8b5f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,226 @@ +"""Tests for ``fixyourdocs._cli.run_cli``.""" + +from __future__ import annotations + +import io +import json +import os +from collections.abc import Iterator +from pathlib import Path + +import httpx +import pytest +import respx + +from fixyourdocs._cli import run_cli +from fixyourdocs.snippet import SNIPPET_HEADING + +API_URL = "https://hub.example.test" +REPORTS_URL = f"{API_URL}/v1/reports" + + +def _io() -> tuple[io.StringIO, io.StringIO]: + return io.StringIO(), io.StringIO() + + +@pytest.fixture +def cwd(tmp_path: Path) -> Iterator[Path]: + original = Path.cwd() + os.chdir(tmp_path) + try: + yield tmp_path + finally: + os.chdir(original) + + +def test_version_prints_package_version() -> None: + out, err = _io() + code = run_cli(["--version"], stdout=out, stderr=err) + # argparse writes --version output to stdout itself; we map its exit + # to our return code. + assert code == 0 + + +def test_help_returns_zero() -> None: + out, err = _io() + code = run_cli(["--help"], stdout=out, stderr=err) + assert code == 0 + + +def test_unknown_subcommand_returns_two() -> None: + out, err = _io() + code = run_cli(["bogus"], stdout=out, stderr=err) + assert code == 2 + + +def test_init_creates_agents_md(cwd: Path) -> None: + out, err = _io() + code = run_cli(["init"], stdout=out, stderr=err) + assert code == 0 + target = cwd / "AGENTS.md" + assert SNIPPET_HEADING in target.read_text() + assert "Created" in out.getvalue() + + +def test_init_json_output(cwd: Path) -> None: + out, err = _io() + code = run_cli(["init", "--json"], stdout=out, stderr=err) + assert code == 0 + parsed = json.loads(out.getvalue()) + assert parsed["created"] is True + assert parsed["changed"] is True + assert parsed["path"] == str((cwd / "AGENTS.md").resolve()) + + +def test_init_idempotent_second_run_reports_no_change(cwd: Path) -> None: + run_cli(["init"], stdout=io.StringIO(), stderr=io.StringIO()) + out, err = _io() + code = run_cli(["init"], stdout=out, stderr=err) + assert code == 0 + assert "No changes" in out.getvalue() + + +def test_init_rejects_unknown_flag(cwd: Path) -> None: + out, err = _io() + code = run_cli(["init", "--nope"], stdout=out, stderr=err) + assert code == 2 + + +@respx.mock +def test_report_happy_path() -> None: + captured: dict[str, object] = {} + + def _ack(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 201, + json={ + "id": "rep_cli_py_01", + "received_at": "2026-06-06T12:34:56Z", + "protocol_version": "0", + "server_capabilities": [], + }, + ) + + respx.post(REPORTS_URL).mock(side_effect=_ack) + out, err = _io() + code = run_cli( + [ + "report", + "--api-url", + API_URL, + "--doc-url", + "https://example.com/docs/install", + "--summary", + "Install fails on macOS 14", + "--agent", + "claude-code", + "--kind", + "broken", + ], + stdout=out, + stderr=err, + ) + assert code == 0 + assert "rep_cli_py_01" in out.getvalue() + body = captured["body"] + assert isinstance(body, dict) + assert body["protocol_version"] == "0" + assert body["doc_url"] == "https://example.com/docs/install" + assert body["agent"]["name"] == "claude-code" + assert body["report"]["kind"] == "broken" + + +def test_report_missing_required_returns_two() -> None: + out, err = _io() + code = run_cli( + ["report", "--api-url", API_URL], + stdout=out, + stderr=err, + ) + assert code == 2 + + +def test_report_bad_kind_returns_two() -> None: + out, err = _io() + code = run_cli( + [ + "report", + "--api-url", + API_URL, + "--doc-url", + "https://example.com", + "--summary", + "x", + "--agent", + "claude-code", + "--kind", + "weird", + ], + stdout=out, + stderr=err, + ) + assert code == 2 + + +@respx.mock +def test_report_server_error_returns_one() -> None: + respx.post(REPORTS_URL).mock( + return_value=httpx.Response( + 422, + json={"error": "policy_rejected", "reason": "unknown agent.name"}, + ) + ) + out, err = _io() + code = run_cli( + [ + "report", + "--api-url", + API_URL, + "--doc-url", + "https://example.com", + "--summary", + "x", + "--agent", + "claude-code", + ], + stdout=out, + stderr=err, + ) + assert code == 1 + assert "policy_rejected" in err.getvalue() + + +@respx.mock +def test_report_json_output() -> None: + respx.post(REPORTS_URL).mock( + return_value=httpx.Response( + 201, + json={ + "id": "rep_cli_py_json", + "received_at": "2026-06-06T12:34:56Z", + "protocol_version": "0", + "server_capabilities": [], + }, + ) + ) + out, err = _io() + code = run_cli( + [ + "report", + "--api-url", + API_URL, + "--doc-url", + "https://example.com", + "--summary", + "x", + "--agent", + "claude-code", + "--json", + ], + stdout=out, + stderr=err, + ) + assert code == 0 + parsed = json.loads(out.getvalue()) + assert parsed["id"] == "rep_cli_py_json" diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..4c2ab8f --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,58 @@ +"""Tests for ``fixyourdocs._init.run_init``.""" + +from __future__ import annotations + +from pathlib import Path + +from fixyourdocs._init import run_init +from fixyourdocs.snippet import SNIPPET, SNIPPET_HEADING + + +def test_creates_agents_md_when_nothing_exists(tmp_path: Path) -> None: + result = run_init(tmp_path) + assert result.created is True + assert result.changed is True + assert result.path == (tmp_path / "AGENTS.md").resolve() + assert (tmp_path / "AGENTS.md").read_text() == SNIPPET + + +def test_appends_to_existing_agents_md(tmp_path: Path) -> None: + target = tmp_path / "AGENTS.md" + target.write_text("# My repo\n\nDo things.") + result = run_init(tmp_path) + assert result.created is False + assert result.changed is True + after = target.read_text() + assert after.startswith("# My repo\n\nDo things.") + assert SNIPPET_HEADING in after + + +def test_idempotent_on_second_run(tmp_path: Path) -> None: + run_init(tmp_path) + first = (tmp_path / "AGENTS.md").read_text() + result = run_init(tmp_path) + assert result.changed is False + assert (tmp_path / "AGENTS.md").read_text() == first + + +def test_prefers_claude_md_when_only_claude_exists(tmp_path: Path) -> None: + target = tmp_path / "CLAUDE.md" + target.write_text("# Claude rules\n") + result = run_init(tmp_path) + assert result.path == target.resolve() + assert SNIPPET_HEADING in target.read_text() + + +def test_prefers_agents_md_when_both_present(tmp_path: Path) -> None: + (tmp_path / "AGENTS.md").write_text("# Agents\n") + (tmp_path / "CLAUDE.md").write_text("# Claude\n") + result = run_init(tmp_path) + assert result.path == (tmp_path / "AGENTS.md").resolve() + + +def test_explicit_file_override(tmp_path: Path) -> None: + result = run_init(tmp_path, file="INSTRUCTIONS.md") + target = tmp_path / "INSTRUCTIONS.md" + assert result.path == target.resolve() + assert result.created is True + assert target.read_text() == SNIPPET