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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
193 changes: 193 additions & 0 deletions src/fixyourdocs/_cli.py
Original file line number Diff line number Diff line change
@@ -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="<command>")

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:]))
61 changes: 61 additions & 0 deletions src/fixyourdocs/_init.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions src/fixyourdocs/snippet.py
Original file line number Diff line number Diff line change
@@ -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": "<URL or path of the doc you were reading>",
"summary": "<one-line description of the problem>",
"details": "<what you expected vs. what the doc said>",
"context": "<the task you were trying to complete>"
}'

Spec: https://docsfeedback.org
"""
Loading
Loading