fixyourdocs 0.1.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. fixyourdocs-0.2.1/.github/scripts/check_snippet_drift.py +138 -0
  2. fixyourdocs-0.2.1/.github/workflows/snippet-drift.yml +22 -0
  3. fixyourdocs-0.2.1/AGENTS.md +30 -0
  4. fixyourdocs-0.2.1/CHANGELOG.md +20 -0
  5. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/CODE_OF_CONDUCT.md +1 -1
  6. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/CONTRIBUTING.md +1 -1
  7. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/PKG-INFO +29 -2
  8. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/README.md +27 -0
  9. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/pyproject.toml +5 -2
  10. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/src/fixyourdocs/__init__.py +1 -1
  11. fixyourdocs-0.2.1/src/fixyourdocs/_cli.py +191 -0
  12. fixyourdocs-0.2.1/src/fixyourdocs/_init.py +61 -0
  13. fixyourdocs-0.2.1/src/fixyourdocs/snippet.py +37 -0
  14. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/tests/test_async_client.py +1 -1
  15. fixyourdocs-0.2.1/tests/test_cli.py +226 -0
  16. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/tests/test_client.py +1 -1
  17. fixyourdocs-0.2.1/tests/test_init.py +58 -0
  18. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/tests/test_smoke.py +1 -1
  19. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/.github/workflows/ci.yml +0 -0
  20. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/.github/workflows/cla.yml +0 -0
  21. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/.github/workflows/publish.yml +0 -0
  22. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/.gitignore +0 -0
  23. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/LICENSE +0 -0
  24. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/src/fixyourdocs/_http.py +0 -0
  25. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/src/fixyourdocs/_results.py +0 -0
  26. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/src/fixyourdocs/client.py +0 -0
  27. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/src/fixyourdocs/errors.py +0 -0
  28. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/src/fixyourdocs/models.py +0 -0
  29. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/src/fixyourdocs/py.typed +0 -0
  30. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/tests/__init__.py +0 -0
  31. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/tests/fixtures/full.json +0 -0
  32. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/tests/fixtures/golden-path.json +0 -0
  33. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/tests/fixtures/invalid.json +0 -0
  34. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/tests/fixtures/minimum-required.json +0 -0
  35. {fixyourdocs-0.1.0 → fixyourdocs-0.2.1}/tests/test_models.py +0 -0
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Verify the vendored AGENTS.md snippet matches the canonical upstream.
3
+
4
+ Pulls https://raw.githubusercontent.com/fixyourdocs/agents-md-snippet/main/README.md,
5
+ extracts the fenced ```markdown block, hashes it, and compares to the
6
+ SHA-256 of the ``SNIPPET`` constant exported by
7
+ ``src/fixyourdocs/snippet.py``.
8
+
9
+ Run from the repo root:
10
+
11
+ python3 .github/scripts/check_snippet_drift.py
12
+
13
+ Tolerance for cross-repo content locks: if upstream `main` does not match
14
+ but an open PR on `agents-md-snippet` carries a matching block, this
15
+ exits 0 with a warning. This is the legitimate "the SDK PR re-vendors
16
+ what the upstream PR is about to merge" case. Once that upstream PR
17
+ merges, the warning goes away.
18
+
19
+ Exit code 0 on match (or matched open PR), 1 on real drift.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import pathlib
27
+ import re
28
+ import sys
29
+ import urllib.error
30
+ import urllib.request
31
+
32
+ OWNER = "fixyourdocs"
33
+ REPO = "agents-md-snippet"
34
+ RAW_TEMPLATE = "https://raw.githubusercontent.com/{owner}/{repo}/{ref}/README.md"
35
+ PULLS_URL = f"https://api.github.com/repos/{OWNER}/{REPO}/pulls?state=open&per_page=30"
36
+
37
+ ROOT = pathlib.Path(__file__).resolve().parents[2]
38
+ VENDORED = ROOT / "src" / "fixyourdocs" / "snippet.py"
39
+
40
+
41
+ def fail(msg: str) -> None:
42
+ print(f"snippet-drift: {msg}", file=sys.stderr)
43
+ sys.exit(1)
44
+
45
+
46
+ def fetch_text(url: str) -> str:
47
+ req = urllib.request.Request(url, headers={"User-Agent": "snippet-drift"})
48
+ with urllib.request.urlopen(req, timeout=30) as resp:
49
+ return resp.read().decode("utf-8")
50
+
51
+
52
+ def fetch_block_at_ref(ref: str) -> str | None:
53
+ url = RAW_TEMPLATE.format(owner=OWNER, repo=REPO, ref=ref)
54
+ try:
55
+ text = fetch_text(url)
56
+ except urllib.error.HTTPError:
57
+ return None
58
+ matches = re.findall(r"```markdown\n(.*?)\n```", text, flags=re.DOTALL)
59
+ if len(matches) != 1:
60
+ return None
61
+ return matches[0]
62
+
63
+
64
+ def extract_vendored_block(path: pathlib.Path) -> str:
65
+ text = path.read_text()
66
+ match = re.search(
67
+ r'SNIPPET = """\\?\n(.*?)"""',
68
+ text,
69
+ flags=re.DOTALL,
70
+ )
71
+ if not match:
72
+ fail(f"could not find `SNIPPET = \"\"\"` in {path.relative_to(ROOT)}")
73
+ raw = match.group(1)
74
+ raw = raw.replace("\\\\", "\\")
75
+ return raw.rstrip("\n")
76
+
77
+
78
+ def sha256(s: str) -> str:
79
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
80
+
81
+
82
+ def matching_open_pr(vendored: str) -> dict | None:
83
+ try:
84
+ body = fetch_text(PULLS_URL)
85
+ except urllib.error.HTTPError:
86
+ return None
87
+ prs = json.loads(body)
88
+ for pr in prs:
89
+ ref = pr.get("head", {}).get("ref")
90
+ if not ref:
91
+ continue
92
+ block = fetch_block_at_ref(ref)
93
+ if block is not None and block == vendored:
94
+ return pr
95
+ return None
96
+
97
+
98
+ def main() -> None:
99
+ upstream = fetch_block_at_ref("main")
100
+ if upstream is None:
101
+ fail("could not extract a single ```markdown block from upstream main README")
102
+ vendored = extract_vendored_block(VENDORED)
103
+ if upstream == vendored:
104
+ print(f"snippet-drift: in sync with main (sha256 {sha256(upstream)[:12]}…)")
105
+ return
106
+
107
+ pr = matching_open_pr(vendored)
108
+ if pr is not None:
109
+ print(
110
+ "snippet-drift: WARNING — main is out of sync with the vendored copy, "
111
+ f"but open upstream PR #{pr['number']} ({pr['title']!r}) matches it "
112
+ f"(sha256 {sha256(vendored)[:12]}…).\n"
113
+ " This is the cross-repo content-lock window. Re-run after that PR "
114
+ "merges to confirm."
115
+ )
116
+ return
117
+
118
+ u = sha256(upstream)
119
+ v = sha256(vendored)
120
+ diff_pos = next(
121
+ (i for i, (a, b) in enumerate(zip(upstream, vendored)) if a != b),
122
+ min(len(upstream), len(vendored)),
123
+ )
124
+ fail(
125
+ "vendored snippet has drifted from the upstream block; no open PR "
126
+ "carries a matching block either.\n"
127
+ f" upstream main sha256: {u}\n"
128
+ f" vendored sha256: {v}\n"
129
+ f" first byte diff at offset {diff_pos}:\n"
130
+ f" upstream: {upstream[max(0, diff_pos - 16): diff_pos + 64]!r}\n"
131
+ f" vendored: {vendored[max(0, diff_pos - 16): diff_pos + 64]!r}\n"
132
+ "Re-vendor src/fixyourdocs/snippet.py from "
133
+ "https://github.com/fixyourdocs/agents-md-snippet (README block)."
134
+ )
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
@@ -0,0 +1,22 @@
1
+ name: snippet-drift
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ schedule:
8
+ - cron: "17 6 * * *" # daily, so an upstream-only edit also turns this red.
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ check:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.12"
21
+ - name: Compare vendored snippet to upstream
22
+ run: python3 .github/scripts/check_snippet_drift.py
@@ -0,0 +1,30 @@
1
+ # AGENTS.md
2
+
3
+ Dogfood — this is the same canonical block this SDK's `fixyourdocs init`
4
+ writes into your repo. The block lives at
5
+ [fixyourdocs/agents-md-snippet](https://github.com/fixyourdocs/agents-md-snippet);
6
+ this copy is SHA-pinned to it (see `.github/workflows/snippet-drift.yml`).
7
+
8
+ ## Documentation feedback
9
+
10
+ If you find documentation in this repository that is broken, incorrect,
11
+ outdated, or incomplete while performing a task, file a structured report
12
+ via the Docs Feedback Protocol. This helps the maintainers fix the docs
13
+ for the next agent (or human) that hits the same issue.
14
+
15
+ How to file a report:
16
+
17
+ curl -X POST https://hub.fixyourdocs.io/v1/reports \
18
+ -H 'Content-Type: application/json' \
19
+ -d '{
20
+ "protocol_version": "0",
21
+ "doc_url": "<URL or path of the doc you were reading>",
22
+ "agent": { "name": "<your agent id, e.g. claude-code>" },
23
+ "report": {
24
+ "kind": "<broken | incorrect | outdated | missing | unclear | other>",
25
+ "summary": "<one-line description of the problem>",
26
+ "details": "<what you expected vs. what the doc said (optional)>"
27
+ }
28
+ }'
29
+
30
+ Spec: https://docsfeedback.org
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## v0.2.1 — 2026-06-03
4
+
5
+ - Publish the dropped `View:` CLI line: `fixyourdocs report` no longer
6
+ prints a `View: https://hub.fixyourdocs.io/r/<id>` link (that route
7
+ 404s; the code change merged in 0.2.0 but was never released). The Hub
8
+ also retired the public `GET /v1/reports/{id}` read endpoint — the SDK
9
+ is POST-only (`Client.send()`), so this is a release-only change with
10
+ no client API difference.
11
+
12
+ ## v0.2.0 — 2026-05-26
13
+
14
+ - Add `fixyourdocs init` CLI (appends canonical snippet from `agents-md-snippet` to `AGENTS.md` / `CLAUDE.md` / `.cursor/rules` / `.github/copilot-instructions.md`; idempotent).
15
+ - Add `fixyourdocs report` CLI (sends a Docs Feedback Protocol v0 report; supports `--json`, exit codes 0/1/2).
16
+ - Embed canonical snippet from `agents-md-snippet`; CI fails on drift.
17
+
18
+ ## v0.1.0 — initial release
19
+
20
+ - Typed client for Docs Feedback Protocol v0.
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
36
36
 
37
37
  ## Enforcement
38
38
 
39
- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hello@fixyourdocs.org. All complaints will be reviewed and investigated promptly and fairly.
39
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hello@fixyourdocs.io. All complaints will be reviewed and investigated promptly and fairly.
40
40
 
41
41
  All community leaders are obligated to respect the privacy and security of the reporter of any incident.
42
42
 
@@ -33,4 +33,4 @@ comment on your PR with a one-click sign-off link. See the CLA text at
33
33
 
34
34
  By participating you agree to abide by the
35
35
  [Code of Conduct](CODE_OF_CONDUCT.md) (Contributor Covenant 2.1).
36
- Report incidents to <hello@fixyourdocs.org>.
36
+ Report incidents to <hello@fixyourdocs.io>.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixyourdocs
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Reference Python SDK for the Docs Feedback Protocol
5
5
  Project-URL: Homepage, https://docsfeedback.org
6
6
  Project-URL: Repository, https://github.com/fixyourdocs/sdk-python
7
7
  Project-URL: Specification, https://github.com/fixyourdocs/protocol
8
- Author-email: FixYourDocs <hello@fixyourdocs.org>
8
+ Author-email: FixYourDocs <hello@fixyourdocs.io>
9
9
  License-Expression: Apache-2.0
10
10
  License-File: LICENSE
11
11
  Keywords: agents,ai,docs,documentation,feedback,protocol
@@ -37,6 +37,7 @@ Reference Python SDK for the [Docs Feedback Protocol](https://github.com/fixyour
37
37
  v0. The protocol lets AI agents file structured reports against
38
38
  documentation pages when the docs break agent task flows.
39
39
 
40
+ - Full docs: <https://docs.fixyourdocs.io/sdk/python/>.
40
41
  - Spec: <https://docsfeedback.org>.
41
42
  - Why this exists: [the FixYourDocs manifesto](https://github.com/fixyourdocs/manifesto/blob/main/MANIFESTO.md).
42
43
 
@@ -48,6 +49,32 @@ pip install fixyourdocs
48
49
 
49
50
  Requires Python 3.9+.
50
51
 
52
+ ## CLI
53
+
54
+ The package ships a `fixyourdocs` console script covering the two
55
+ one-liners from the [agents-md-snippet](https://github.com/fixyourdocs/agents-md-snippet)
56
+ README:
57
+
58
+ ```sh
59
+ # Adds the canonical AGENTS.md block to your repo. Idempotent.
60
+ pipx run fixyourdocs init
61
+
62
+ # Sends a single report to the Hub.
63
+ pipx run fixyourdocs report \
64
+ --doc-url https://example.com/docs/install \
65
+ --summary "Install fails on macOS 14" \
66
+ --agent claude-code \
67
+ --kind broken
68
+ ```
69
+
70
+ `init` auto-detects `AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, or
71
+ `.github/copilot-instructions.md` and appends to whichever exists
72
+ (falling back to creating `AGENTS.md`). Pass `--file <path>` to override.
73
+
74
+ `report` accepts `--details`, `--suggested-fix`, `--api-url`, `--token`,
75
+ and `--json` for machine-readable output. Exit codes: `0` success,
76
+ `2` user error, `1` transport or server error.
77
+
51
78
  ## Usage (sync)
52
79
 
53
80
  ```python
@@ -4,6 +4,7 @@ Reference Python SDK for the [Docs Feedback Protocol](https://github.com/fixyour
4
4
  v0. The protocol lets AI agents file structured reports against
5
5
  documentation pages when the docs break agent task flows.
6
6
 
7
+ - Full docs: <https://docs.fixyourdocs.io/sdk/python/>.
7
8
  - Spec: <https://docsfeedback.org>.
8
9
  - Why this exists: [the FixYourDocs manifesto](https://github.com/fixyourdocs/manifesto/blob/main/MANIFESTO.md).
9
10
 
@@ -15,6 +16,32 @@ pip install fixyourdocs
15
16
 
16
17
  Requires Python 3.9+.
17
18
 
19
+ ## CLI
20
+
21
+ The package ships a `fixyourdocs` console script covering the two
22
+ one-liners from the [agents-md-snippet](https://github.com/fixyourdocs/agents-md-snippet)
23
+ README:
24
+
25
+ ```sh
26
+ # Adds the canonical AGENTS.md block to your repo. Idempotent.
27
+ pipx run fixyourdocs init
28
+
29
+ # Sends a single report to the Hub.
30
+ pipx run fixyourdocs report \
31
+ --doc-url https://example.com/docs/install \
32
+ --summary "Install fails on macOS 14" \
33
+ --agent claude-code \
34
+ --kind broken
35
+ ```
36
+
37
+ `init` auto-detects `AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, or
38
+ `.github/copilot-instructions.md` and appends to whichever exists
39
+ (falling back to creating `AGENTS.md`). Pass `--file <path>` to override.
40
+
41
+ `report` accepts `--details`, `--suggested-fix`, `--api-url`, `--token`,
42
+ and `--json` for machine-readable output. Exit codes: `0` success,
43
+ `2` user error, `1` transport or server error.
44
+
18
45
  ## Usage (sync)
19
46
 
20
47
  ```python
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fixyourdocs"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Reference Python SDK for the Docs Feedback Protocol"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
11
  license = "Apache-2.0"
12
12
  license-files = ["LICENSE"]
13
- authors = [{ name = "FixYourDocs", email = "hello@fixyourdocs.org" }]
13
+ authors = [{ name = "FixYourDocs", email = "hello@fixyourdocs.io" }]
14
14
  keywords = ["docs", "documentation", "agents", "ai", "feedback", "protocol"]
15
15
  classifiers = [
16
16
  "Development Status :: 3 - Alpha",
@@ -44,6 +44,9 @@ Homepage = "https://docsfeedback.org"
44
44
  Repository = "https://github.com/fixyourdocs/sdk-python"
45
45
  Specification = "https://github.com/fixyourdocs/protocol"
46
46
 
47
+ [project.scripts]
48
+ fixyourdocs = "fixyourdocs._cli:main"
49
+
47
50
  [tool.hatch.build.targets.wheel]
48
51
  packages = ["src/fixyourdocs"]
49
52
 
@@ -5,7 +5,7 @@ See <https://github.com/fixyourdocs/protocol> for the wire-format spec.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.2.1"
9
9
 
10
10
  from fixyourdocs._results import SendResult
11
11
  from fixyourdocs.client import AsyncClient, Client
@@ -0,0 +1,191 @@
1
+ """``fixyourdocs`` CLI — ``init`` + ``report`` subcommands.
2
+
3
+ Exit codes:
4
+
5
+ * ``0`` — success.
6
+ * ``2`` — user / argument error (unknown / missing flag, bad enum).
7
+ * ``1`` — transport or server error.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+ from typing import IO, Optional
18
+
19
+ from fixyourdocs import __version__
20
+ from fixyourdocs._init import run_init
21
+ from fixyourdocs.client import Client
22
+ from fixyourdocs.errors import FixYourDocsError
23
+ from fixyourdocs.models import Report, ReportKind
24
+
25
+ DEFAULT_API_URL = "https://hub.fixyourdocs.io"
26
+ _REPORT_KINDS = tuple(k.value for k in ReportKind)
27
+
28
+
29
+ def _build_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(
31
+ prog="fixyourdocs",
32
+ description=(
33
+ "FixYourDocs CLI — adds the canonical AGENTS.md block to your "
34
+ "repo and sends Docs Feedback Protocol v0 reports."
35
+ ),
36
+ )
37
+ parser.add_argument(
38
+ "-v", "--version", action="version", version=__version__
39
+ )
40
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
41
+
42
+ init_p = sub.add_parser(
43
+ "init",
44
+ help="Append the canonical AGENTS.md block to your repo.",
45
+ )
46
+ init_p.add_argument(
47
+ "--file",
48
+ help=(
49
+ "Explicit target file (skips auto-detection of AGENTS.md / "
50
+ "CLAUDE.md / .cursor/rules / .github/copilot-instructions.md)."
51
+ ),
52
+ )
53
+ init_p.add_argument(
54
+ "--json",
55
+ action="store_true",
56
+ help="Emit machine-readable JSON instead of plain text.",
57
+ )
58
+
59
+ rep = sub.add_parser(
60
+ "report",
61
+ help="Send a Docs Feedback Protocol v0 report to the Hub.",
62
+ )
63
+ rep.add_argument("--doc-url", required=True)
64
+ rep.add_argument("--summary", required=True)
65
+ rep.add_argument("--agent", required=True, dest="agent_name")
66
+ rep.add_argument(
67
+ "--kind",
68
+ choices=_REPORT_KINDS,
69
+ default="other",
70
+ )
71
+ rep.add_argument("--details")
72
+ rep.add_argument("--suggested-fix", dest="suggested_fix")
73
+ rep.add_argument("--api-url", default=DEFAULT_API_URL, dest="api_url")
74
+ rep.add_argument("--token")
75
+ rep.add_argument(
76
+ "--json",
77
+ action="store_true",
78
+ help="Emit machine-readable JSON instead of plain text.",
79
+ )
80
+ return parser
81
+
82
+
83
+ def _do_init(args: argparse.Namespace, stdout: IO[str]) -> int:
84
+ result = run_init(cwd=Path.cwd(), file=args.file)
85
+ if args.json:
86
+ stdout.write(
87
+ json.dumps(
88
+ {
89
+ "path": str(result.path),
90
+ "changed": result.changed,
91
+ "created": result.created,
92
+ }
93
+ )
94
+ + "\n"
95
+ )
96
+ elif result.created:
97
+ stdout.write(f"Created {result.path} with the FixYourDocs snippet.\n")
98
+ elif result.changed:
99
+ stdout.write(
100
+ f"Appended the FixYourDocs snippet to {result.path}.\n"
101
+ )
102
+ else:
103
+ stdout.write(
104
+ f"No changes — snippet already present in {result.path}.\n"
105
+ )
106
+ return 0
107
+
108
+
109
+ def _do_report(
110
+ args: argparse.Namespace, stdout: IO[str], stderr: IO[str]
111
+ ) -> int:
112
+ try:
113
+ report = Report.create(
114
+ doc_url=args.doc_url,
115
+ summary=args.summary,
116
+ kind=args.kind,
117
+ agent_name=args.agent_name,
118
+ details=args.details,
119
+ suggested_fix=args.suggested_fix,
120
+ )
121
+ except (ValueError, FixYourDocsError) as err:
122
+ stderr.write(f"error: {err}\n")
123
+ return 2
124
+
125
+ api_url = args.api_url.rstrip("/")
126
+ with Client(api_url, token=args.token) as client:
127
+ try:
128
+ result = client.send(report)
129
+ except FixYourDocsError as err:
130
+ if args.json:
131
+ stdout.write(
132
+ json.dumps(
133
+ {"error": type(err).__name__, "message": str(err)}
134
+ )
135
+ + "\n"
136
+ )
137
+ else:
138
+ stderr.write(f"error: {err}\n")
139
+ return 1
140
+
141
+ if args.json:
142
+ stdout.write(
143
+ json.dumps(
144
+ {
145
+ "id": result.id,
146
+ "received_at": result.received_at.isoformat(),
147
+ "is_duplicate": result.is_duplicate,
148
+ }
149
+ )
150
+ + "\n"
151
+ )
152
+ else:
153
+ label = "Duplicate report" if result.is_duplicate else "Report accepted"
154
+ stdout.write(f"{label}: {result.id}\n")
155
+ return 0
156
+
157
+
158
+ def run_cli(
159
+ argv: Optional[Sequence[str]] = None,
160
+ *,
161
+ stdout: Optional[IO[str]] = None,
162
+ stderr: Optional[IO[str]] = None,
163
+ ) -> int:
164
+ """Entry point used by both the console_script and the tests.
165
+
166
+ Returns the desired process exit code; never calls ``sys.exit``
167
+ itself.
168
+ """
169
+ out = stdout if stdout is not None else sys.stdout
170
+ err = stderr if stderr is not None else sys.stderr
171
+
172
+ parser = _build_parser()
173
+ # argparse calls sys.exit on errors; intercept to map to our exit codes.
174
+ try:
175
+ args = parser.parse_args(argv)
176
+ except SystemExit as exc:
177
+ return 0 if exc.code == 0 else 2
178
+
179
+ if args.command is None:
180
+ parser.print_help(out)
181
+ return 0
182
+ if args.command == "init":
183
+ return _do_init(args, out)
184
+ if args.command == "report":
185
+ return _do_report(args, out, err)
186
+ err.write(f"error: unknown command {args.command!r}\n")
187
+ return 2
188
+
189
+
190
+ def main() -> None: # console_script entry point
191
+ sys.exit(run_cli(sys.argv[1:]))
@@ -0,0 +1,61 @@
1
+ """``fixyourdocs init`` — append the canonical AGENTS.md block."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from fixyourdocs.snippet import SNIPPET, SNIPPET_HEADING
10
+
11
+ TARGET_CANDIDATES = (
12
+ "AGENTS.md",
13
+ "CLAUDE.md",
14
+ ".cursor/rules",
15
+ ".github/copilot-instructions.md",
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class InitResult:
21
+ path: Path
22
+ changed: bool
23
+ created: bool
24
+
25
+
26
+ def _pick_default_target(cwd: Path) -> str:
27
+ for candidate in TARGET_CANDIDATES:
28
+ if (cwd / candidate).exists():
29
+ return candidate
30
+ return "AGENTS.md"
31
+
32
+
33
+ def _with_trailing_blank_line(s: str) -> str:
34
+ if not s:
35
+ return ""
36
+ if s.endswith("\n\n"):
37
+ return s
38
+ if s.endswith("\n"):
39
+ return s + "\n"
40
+ return s + "\n\n"
41
+
42
+
43
+ def run_init(cwd: Path, file: Optional[str] = None) -> InitResult:
44
+ """Append the canonical AGENTS.md block to the chosen target file.
45
+
46
+ Idempotent: if the block heading is already present, returns
47
+ ``InitResult(changed=False)`` and leaves the file untouched.
48
+ """
49
+ target_rel = file if file is not None else _pick_default_target(cwd)
50
+ path = (cwd / target_rel).resolve()
51
+
52
+ if not path.exists():
53
+ path.write_text(SNIPPET)
54
+ return InitResult(path=path, changed=True, created=True)
55
+
56
+ current = path.read_text()
57
+ if SNIPPET_HEADING in current:
58
+ return InitResult(path=path, changed=False, created=False)
59
+
60
+ path.write_text(_with_trailing_blank_line(current) + SNIPPET)
61
+ return InitResult(path=path, changed=True, created=False)
@@ -0,0 +1,37 @@
1
+ """Canonical AGENTS.md block.
2
+
3
+ Mirrored from the ``agents-md-snippet`` repo
4
+ (https://github.com/fixyourdocs/agents-md-snippet). The TypeScript SDK
5
+ keeps an identical copy in ``src/snippet.ts``; CI in both repos refuses
6
+ to merge a drift from the upstream code-fenced block.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ SNIPPET_HEADING = "## Documentation feedback"
12
+
13
+ SNIPPET = """\
14
+ ## Documentation feedback
15
+
16
+ If you find documentation in this repository that is broken, incorrect,
17
+ outdated, or incomplete while performing a task, file a structured report
18
+ via the Docs Feedback Protocol. This helps the maintainers fix the docs
19
+ for the next agent (or human) that hits the same issue.
20
+
21
+ How to file a report:
22
+
23
+ curl -X POST https://hub.fixyourdocs.io/v1/reports \\
24
+ -H 'Content-Type: application/json' \\
25
+ -d '{
26
+ "protocol_version": "0",
27
+ "doc_url": "<URL or path of the doc you were reading>",
28
+ "agent": { "name": "<your agent id, e.g. claude-code>" },
29
+ "report": {
30
+ "kind": "<broken | incorrect | outdated | missing | unclear | other>",
31
+ "summary": "<one-line description of the problem>",
32
+ "details": "<what you expected vs. what the doc said (optional)>"
33
+ }
34
+ }'
35
+
36
+ Spec: https://docsfeedback.org
37
+ """
@@ -86,7 +86,7 @@ async def test_headers_with_token_and_idempotency(golden_report: Report) -> None
86
86
  assert headers["Authorization"] == "Bearer opaque-token"
87
87
  assert headers["Idempotency-Key"] == "key-123"
88
88
  assert headers["X-Docs-Feedback-Protocol-Version"] == "0"
89
- assert headers["User-Agent"] == "fixyourdocs-python/0.1.0"
89
+ assert headers["User-Agent"] == "fixyourdocs-python/0.2.1"
90
90
 
91
91
 
92
92
  @respx.mock
@@ -0,0 +1,226 @@
1
+ """Tests for ``fixyourdocs._cli.run_cli``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ from collections.abc import Iterator
9
+ from pathlib import Path
10
+
11
+ import httpx
12
+ import pytest
13
+ import respx
14
+
15
+ from fixyourdocs._cli import run_cli
16
+ from fixyourdocs.snippet import SNIPPET_HEADING
17
+
18
+ API_URL = "https://hub.example.test"
19
+ REPORTS_URL = f"{API_URL}/v1/reports"
20
+
21
+
22
+ def _io() -> tuple[io.StringIO, io.StringIO]:
23
+ return io.StringIO(), io.StringIO()
24
+
25
+
26
+ @pytest.fixture
27
+ def cwd(tmp_path: Path) -> Iterator[Path]:
28
+ original = Path.cwd()
29
+ os.chdir(tmp_path)
30
+ try:
31
+ yield tmp_path
32
+ finally:
33
+ os.chdir(original)
34
+
35
+
36
+ def test_version_prints_package_version() -> None:
37
+ out, err = _io()
38
+ code = run_cli(["--version"], stdout=out, stderr=err)
39
+ # argparse writes --version output to stdout itself; we map its exit
40
+ # to our return code.
41
+ assert code == 0
42
+
43
+
44
+ def test_help_returns_zero() -> None:
45
+ out, err = _io()
46
+ code = run_cli(["--help"], stdout=out, stderr=err)
47
+ assert code == 0
48
+
49
+
50
+ def test_unknown_subcommand_returns_two() -> None:
51
+ out, err = _io()
52
+ code = run_cli(["bogus"], stdout=out, stderr=err)
53
+ assert code == 2
54
+
55
+
56
+ def test_init_creates_agents_md(cwd: Path) -> None:
57
+ out, err = _io()
58
+ code = run_cli(["init"], stdout=out, stderr=err)
59
+ assert code == 0
60
+ target = cwd / "AGENTS.md"
61
+ assert SNIPPET_HEADING in target.read_text()
62
+ assert "Created" in out.getvalue()
63
+
64
+
65
+ def test_init_json_output(cwd: Path) -> None:
66
+ out, err = _io()
67
+ code = run_cli(["init", "--json"], stdout=out, stderr=err)
68
+ assert code == 0
69
+ parsed = json.loads(out.getvalue())
70
+ assert parsed["created"] is True
71
+ assert parsed["changed"] is True
72
+ assert parsed["path"] == str((cwd / "AGENTS.md").resolve())
73
+
74
+
75
+ def test_init_idempotent_second_run_reports_no_change(cwd: Path) -> None:
76
+ run_cli(["init"], stdout=io.StringIO(), stderr=io.StringIO())
77
+ out, err = _io()
78
+ code = run_cli(["init"], stdout=out, stderr=err)
79
+ assert code == 0
80
+ assert "No changes" in out.getvalue()
81
+
82
+
83
+ def test_init_rejects_unknown_flag(cwd: Path) -> None:
84
+ out, err = _io()
85
+ code = run_cli(["init", "--nope"], stdout=out, stderr=err)
86
+ assert code == 2
87
+
88
+
89
+ @respx.mock
90
+ def test_report_happy_path() -> None:
91
+ captured: dict[str, object] = {}
92
+
93
+ def _ack(request: httpx.Request) -> httpx.Response:
94
+ captured["body"] = json.loads(request.content)
95
+ return httpx.Response(
96
+ 201,
97
+ json={
98
+ "id": "rep_cli_py_01",
99
+ "received_at": "2026-06-06T12:34:56Z",
100
+ "protocol_version": "0",
101
+ "server_capabilities": [],
102
+ },
103
+ )
104
+
105
+ respx.post(REPORTS_URL).mock(side_effect=_ack)
106
+ out, err = _io()
107
+ code = run_cli(
108
+ [
109
+ "report",
110
+ "--api-url",
111
+ API_URL,
112
+ "--doc-url",
113
+ "https://example.com/docs/install",
114
+ "--summary",
115
+ "Install fails on macOS 14",
116
+ "--agent",
117
+ "claude-code",
118
+ "--kind",
119
+ "broken",
120
+ ],
121
+ stdout=out,
122
+ stderr=err,
123
+ )
124
+ assert code == 0
125
+ assert "rep_cli_py_01" in out.getvalue()
126
+ body = captured["body"]
127
+ assert isinstance(body, dict)
128
+ assert body["protocol_version"] == "0"
129
+ assert body["doc_url"] == "https://example.com/docs/install"
130
+ assert body["agent"]["name"] == "claude-code"
131
+ assert body["report"]["kind"] == "broken"
132
+
133
+
134
+ def test_report_missing_required_returns_two() -> None:
135
+ out, err = _io()
136
+ code = run_cli(
137
+ ["report", "--api-url", API_URL],
138
+ stdout=out,
139
+ stderr=err,
140
+ )
141
+ assert code == 2
142
+
143
+
144
+ def test_report_bad_kind_returns_two() -> None:
145
+ out, err = _io()
146
+ code = run_cli(
147
+ [
148
+ "report",
149
+ "--api-url",
150
+ API_URL,
151
+ "--doc-url",
152
+ "https://example.com",
153
+ "--summary",
154
+ "x",
155
+ "--agent",
156
+ "claude-code",
157
+ "--kind",
158
+ "weird",
159
+ ],
160
+ stdout=out,
161
+ stderr=err,
162
+ )
163
+ assert code == 2
164
+
165
+
166
+ @respx.mock
167
+ def test_report_server_error_returns_one() -> None:
168
+ respx.post(REPORTS_URL).mock(
169
+ return_value=httpx.Response(
170
+ 422,
171
+ json={"error": "policy_rejected", "reason": "unknown agent.name"},
172
+ )
173
+ )
174
+ out, err = _io()
175
+ code = run_cli(
176
+ [
177
+ "report",
178
+ "--api-url",
179
+ API_URL,
180
+ "--doc-url",
181
+ "https://example.com",
182
+ "--summary",
183
+ "x",
184
+ "--agent",
185
+ "claude-code",
186
+ ],
187
+ stdout=out,
188
+ stderr=err,
189
+ )
190
+ assert code == 1
191
+ assert "policy_rejected" in err.getvalue()
192
+
193
+
194
+ @respx.mock
195
+ def test_report_json_output() -> None:
196
+ respx.post(REPORTS_URL).mock(
197
+ return_value=httpx.Response(
198
+ 201,
199
+ json={
200
+ "id": "rep_cli_py_json",
201
+ "received_at": "2026-06-06T12:34:56Z",
202
+ "protocol_version": "0",
203
+ "server_capabilities": [],
204
+ },
205
+ )
206
+ )
207
+ out, err = _io()
208
+ code = run_cli(
209
+ [
210
+ "report",
211
+ "--api-url",
212
+ API_URL,
213
+ "--doc-url",
214
+ "https://example.com",
215
+ "--summary",
216
+ "x",
217
+ "--agent",
218
+ "claude-code",
219
+ "--json",
220
+ ],
221
+ stdout=out,
222
+ stderr=err,
223
+ )
224
+ assert code == 0
225
+ parsed = json.loads(out.getvalue())
226
+ assert parsed["id"] == "rep_cli_py_json"
@@ -94,7 +94,7 @@ def test_default_headers_no_token(golden_report: Report) -> None:
94
94
  headers = route.calls.last.request.headers
95
95
  assert headers["Content-Type"].startswith("application/json")
96
96
  assert headers["X-Docs-Feedback-Protocol-Version"] == "0"
97
- assert headers["User-Agent"] == "fixyourdocs-python/0.1.0"
97
+ assert headers["User-Agent"] == "fixyourdocs-python/0.2.1"
98
98
  assert "Authorization" not in headers
99
99
  assert "Idempotency-Key" not in headers
100
100
 
@@ -0,0 +1,58 @@
1
+ """Tests for ``fixyourdocs._init.run_init``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fixyourdocs._init import run_init
8
+ from fixyourdocs.snippet import SNIPPET, SNIPPET_HEADING
9
+
10
+
11
+ def test_creates_agents_md_when_nothing_exists(tmp_path: Path) -> None:
12
+ result = run_init(tmp_path)
13
+ assert result.created is True
14
+ assert result.changed is True
15
+ assert result.path == (tmp_path / "AGENTS.md").resolve()
16
+ assert (tmp_path / "AGENTS.md").read_text() == SNIPPET
17
+
18
+
19
+ def test_appends_to_existing_agents_md(tmp_path: Path) -> None:
20
+ target = tmp_path / "AGENTS.md"
21
+ target.write_text("# My repo\n\nDo things.")
22
+ result = run_init(tmp_path)
23
+ assert result.created is False
24
+ assert result.changed is True
25
+ after = target.read_text()
26
+ assert after.startswith("# My repo\n\nDo things.")
27
+ assert SNIPPET_HEADING in after
28
+
29
+
30
+ def test_idempotent_on_second_run(tmp_path: Path) -> None:
31
+ run_init(tmp_path)
32
+ first = (tmp_path / "AGENTS.md").read_text()
33
+ result = run_init(tmp_path)
34
+ assert result.changed is False
35
+ assert (tmp_path / "AGENTS.md").read_text() == first
36
+
37
+
38
+ def test_prefers_claude_md_when_only_claude_exists(tmp_path: Path) -> None:
39
+ target = tmp_path / "CLAUDE.md"
40
+ target.write_text("# Claude rules\n")
41
+ result = run_init(tmp_path)
42
+ assert result.path == target.resolve()
43
+ assert SNIPPET_HEADING in target.read_text()
44
+
45
+
46
+ def test_prefers_agents_md_when_both_present(tmp_path: Path) -> None:
47
+ (tmp_path / "AGENTS.md").write_text("# Agents\n")
48
+ (tmp_path / "CLAUDE.md").write_text("# Claude\n")
49
+ result = run_init(tmp_path)
50
+ assert result.path == (tmp_path / "AGENTS.md").resolve()
51
+
52
+
53
+ def test_explicit_file_override(tmp_path: Path) -> None:
54
+ result = run_init(tmp_path, file="INSTRUCTIONS.md")
55
+ target = tmp_path / "INSTRUCTIONS.md"
56
+ assert result.path == target.resolve()
57
+ assert result.created is True
58
+ assert target.read_text() == SNIPPET
@@ -2,7 +2,7 @@ import fixyourdocs
2
2
 
3
3
 
4
4
  def test_import() -> None:
5
- assert fixyourdocs.__version__ == "0.1.0"
5
+ assert fixyourdocs.__version__ == "0.2.1"
6
6
 
7
7
 
8
8
  def test_public_exports() -> None:
File without changes
File without changes