fixyourdocs 0.2.0__tar.gz → 0.3.0__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 (41) hide show
  1. fixyourdocs-0.3.0/CHANGELOG.md +49 -0
  2. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/CODE_OF_CONDUCT.md +1 -1
  3. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/CONTRIBUTING.md +1 -1
  4. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/PKG-INFO +47 -3
  5. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/README.md +45 -1
  6. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/pyproject.toml +2 -2
  7. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/__init__.py +3 -1
  8. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/_cli.py +23 -12
  9. fixyourdocs-0.3.0/src/fixyourdocs/_discovery.py +133 -0
  10. fixyourdocs-0.3.0/src/fixyourdocs/_init.py +122 -0
  11. fixyourdocs-0.3.0/src/fixyourdocs/_privacy.py +113 -0
  12. fixyourdocs-0.3.0/src/fixyourdocs/client.py +228 -0
  13. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/errors.py +12 -0
  14. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_async_client.py +12 -12
  15. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_cli.py +52 -0
  16. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_client.py +15 -15
  17. fixyourdocs-0.3.0/tests/test_consumer_mode.py +276 -0
  18. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_init.py +46 -1
  19. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_smoke.py +2 -1
  20. fixyourdocs-0.2.0/CHANGELOG.md +0 -11
  21. fixyourdocs-0.2.0/src/fixyourdocs/_init.py +0 -61
  22. fixyourdocs-0.2.0/src/fixyourdocs/client.py +0 -126
  23. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/scripts/check_snippet_drift.py +0 -0
  24. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/workflows/ci.yml +0 -0
  25. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/workflows/cla.yml +0 -0
  26. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/workflows/publish.yml +0 -0
  27. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/workflows/snippet-drift.yml +0 -0
  28. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.gitignore +0 -0
  29. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/AGENTS.md +0 -0
  30. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/LICENSE +0 -0
  31. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/_http.py +0 -0
  32. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/_results.py +0 -0
  33. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/models.py +0 -0
  34. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/py.typed +0 -0
  35. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/snippet.py +0 -0
  36. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/__init__.py +0 -0
  37. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/fixtures/full.json +0 -0
  38. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/fixtures/golden-path.json +0 -0
  39. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/fixtures/invalid.json +0 -0
  40. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/fixtures/minimum-required.json +0 -0
  41. {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_models.py +0 -0
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ ## v0.3.0 — 2026-06-09
4
+
5
+ Consumer-side ("report anywhere") mode. New `Client` / `AsyncClient`
6
+ constructor options, all safe-by-default:
7
+
8
+ - **Client-side privacy guard** (`enforce_privacy=True`, default). Before
9
+ any network call, `send()` validates `doc_url` and raises the new
10
+ `PrivacyError` (no GET, no POST) when the URL is not a public
11
+ `https://` doc page — rejecting non-`https` schemes, missing hosts,
12
+ `localhost` / `.localhost` / `.local` / `.internal`, bare single-label
13
+ hostnames, and IP literals in loopback / private / link-local /
14
+ reserved ranges (including IPv4-mapped-IPv6 forms). Pass
15
+ `enforce_privacy=False` to opt out for first-party / self-hosted use.
16
+ - **Transcript stripping** (`include_transcript=False`, default). The
17
+ serialized body omits `task_context.transcript_excerpt`; if that leaves
18
+ `task_context` empty it is dropped. Set `include_transcript=True` to
19
+ send it. The caller's `Report` is never mutated.
20
+ - **`.well-known` opt-out discovery** (`discover_opt_out=True`, default).
21
+ Before posting, `send()` checks
22
+ `https://<host>/.well-known/docs-feedback.json` and raises
23
+ `OptedOutError` when the host has opted out (`opt_in: false`). Results
24
+ are cached per host with a 24h TTL (network errors are not cached);
25
+ 404 / non-2xx / invalid JSON is treated as opted-in. v0 honours the
26
+ opt-out flag only — a custom endpoint advertised by the well-known is
27
+ not yet followed.
28
+ - **`fixyourdocs init --global`** writes the consumer-side "report stale
29
+ third-party docs" block to a global agent-config file (default
30
+ `~/.claude/CLAUDE.md`, override with `--file`). Idempotent.
31
+
32
+ ## v0.2.1 — 2026-06-03
33
+
34
+ - Publish the dropped `View:` CLI line: `fixyourdocs report` no longer
35
+ prints a `View: https://hub.fixyourdocs.io/r/<id>` link (that route
36
+ 404s; the code change merged in 0.2.0 but was never released). The Hub
37
+ also retired the public `GET /v1/reports/{id}` read endpoint — the SDK
38
+ is POST-only (`Client.send()`), so this is a release-only change with
39
+ no client API difference.
40
+
41
+ ## v0.2.0 — 2026-05-26
42
+
43
+ - Add `fixyourdocs init` CLI (appends canonical snippet from `agents-md-snippet` to `AGENTS.md` / `CLAUDE.md` / `.cursor/rules` / `.github/copilot-instructions.md`; idempotent).
44
+ - Add `fixyourdocs report` CLI (sends a Docs Feedback Protocol v0 report; supports `--json`, exit codes 0/1/2).
45
+ - Embed canonical snippet from `agents-md-snippet`; CI fails on drift.
46
+
47
+ ## v0.1.0 — initial release
48
+
49
+ - 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.2.0
3
+ Version: 0.3.0
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
 
@@ -70,6 +71,13 @@ pipx run fixyourdocs report \
70
71
  `.github/copilot-instructions.md` and appends to whichever exists
71
72
  (falling back to creating `AGENTS.md`). Pass `--file <path>` to override.
72
73
 
74
+ `init --global` instead writes the consumer-side "report stale
75
+ third-party docs" block to a global agent-config file (default
76
+ `~/.claude/CLAUDE.md`, override with `--file`). Use this when you want an
77
+ agent to offer to report broken **third-party** docs it consults across
78
+ all your projects, rather than wiring the per-repo block into one repo.
79
+ It is idempotent.
80
+
73
81
  `report` accepts `--details`, `--suggested-fix`, `--api-url`, `--token`,
74
82
  and `--json` for machine-readable output. Exit codes: `0` success,
75
83
  `2` user error, `1` transport or server error.
@@ -126,6 +134,39 @@ so the SDK exposes two ways to build a `Report`:
126
134
 
127
135
  Both produce identical wire output.
128
136
 
137
+ ## Consumer-side mode
138
+
139
+ When an agent reports against **third-party** docs it consulted (rather
140
+ than your own first-party docs), the client defaults guard against
141
+ leaking private context and against pestering hosts that have opted out.
142
+ All three options are constructor arguments on both `Client` and
143
+ `AsyncClient`:
144
+
145
+ ```python
146
+ from fixyourdocs import Client, PrivacyError, OptedOutError
147
+
148
+ with Client(api_url="https://hub.fixyourdocs.io") as client:
149
+ client.send(report) # guards run before any network call
150
+ ```
151
+
152
+ - **`enforce_privacy=True`** (default) — before any network call,
153
+ validates `report.doc_url` and raises `PrivacyError` (no request is
154
+ made) unless it is a public `https://` page. Rejected: non-`https`
155
+ schemes; missing hosts; `localhost` and `.localhost` / `.local` /
156
+ `.internal` names; bare single-label hostnames; and IP literals in
157
+ loopback / private / link-local / reserved ranges. Pass
158
+ `enforce_privacy=False` for first-party / self-hosted / internal docs.
159
+ - **`include_transcript=False`** (default) — strips
160
+ `task_context.transcript_excerpt` from the wire body (dropping
161
+ `task_context` entirely if nothing else remains) so transcript
162
+ snippets never leave the machine unless you opt in with
163
+ `include_transcript=True`. Your `Report` object is never mutated.
164
+ - **`discover_opt_out=True`** (default) — before posting, fetches
165
+ `https://<doc-host>/.well-known/docs-feedback.json`; if the host
166
+ published `opt_in: false`, `send()` raises `OptedOutError` and does not
167
+ post. The result is cached per host for 24h. Missing/invalid
168
+ well-known files are treated as opted-in.
169
+
129
170
  ## Errors
130
171
 
131
172
  Non-2xx responses raise typed exceptions:
@@ -142,7 +183,10 @@ Non-2xx responses raise typed exceptions:
142
183
  | 429 | `RateLimitedError` (`.retry_after`) |
143
184
  | 5xx | `ServerError` (after one automatic retry on 502/503/504) |
144
185
 
145
- All inherit from `FixYourDocsError`.
186
+ All inherit from `FixYourDocsError`. In consumer-side mode, `send()` may
187
+ also raise `PrivacyError` (non-public `doc_url`) or `OptedOutError`
188
+ (host opted out via its `.well-known` file) *before* any request is
189
+ posted.
146
190
 
147
191
  ## Licence
148
192
 
@@ -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
 
@@ -37,6 +38,13 @@ pipx run fixyourdocs report \
37
38
  `.github/copilot-instructions.md` and appends to whichever exists
38
39
  (falling back to creating `AGENTS.md`). Pass `--file <path>` to override.
39
40
 
41
+ `init --global` instead writes the consumer-side "report stale
42
+ third-party docs" block to a global agent-config file (default
43
+ `~/.claude/CLAUDE.md`, override with `--file`). Use this when you want an
44
+ agent to offer to report broken **third-party** docs it consults across
45
+ all your projects, rather than wiring the per-repo block into one repo.
46
+ It is idempotent.
47
+
40
48
  `report` accepts `--details`, `--suggested-fix`, `--api-url`, `--token`,
41
49
  and `--json` for machine-readable output. Exit codes: `0` success,
42
50
  `2` user error, `1` transport or server error.
@@ -93,6 +101,39 @@ so the SDK exposes two ways to build a `Report`:
93
101
 
94
102
  Both produce identical wire output.
95
103
 
104
+ ## Consumer-side mode
105
+
106
+ When an agent reports against **third-party** docs it consulted (rather
107
+ than your own first-party docs), the client defaults guard against
108
+ leaking private context and against pestering hosts that have opted out.
109
+ All three options are constructor arguments on both `Client` and
110
+ `AsyncClient`:
111
+
112
+ ```python
113
+ from fixyourdocs import Client, PrivacyError, OptedOutError
114
+
115
+ with Client(api_url="https://hub.fixyourdocs.io") as client:
116
+ client.send(report) # guards run before any network call
117
+ ```
118
+
119
+ - **`enforce_privacy=True`** (default) — before any network call,
120
+ validates `report.doc_url` and raises `PrivacyError` (no request is
121
+ made) unless it is a public `https://` page. Rejected: non-`https`
122
+ schemes; missing hosts; `localhost` and `.localhost` / `.local` /
123
+ `.internal` names; bare single-label hostnames; and IP literals in
124
+ loopback / private / link-local / reserved ranges. Pass
125
+ `enforce_privacy=False` for first-party / self-hosted / internal docs.
126
+ - **`include_transcript=False`** (default) — strips
127
+ `task_context.transcript_excerpt` from the wire body (dropping
128
+ `task_context` entirely if nothing else remains) so transcript
129
+ snippets never leave the machine unless you opt in with
130
+ `include_transcript=True`. Your `Report` object is never mutated.
131
+ - **`discover_opt_out=True`** (default) — before posting, fetches
132
+ `https://<doc-host>/.well-known/docs-feedback.json`; if the host
133
+ published `opt_in: false`, `send()` raises `OptedOutError` and does not
134
+ post. The result is cached per host for 24h. Missing/invalid
135
+ well-known files are treated as opted-in.
136
+
96
137
  ## Errors
97
138
 
98
139
  Non-2xx responses raise typed exceptions:
@@ -109,7 +150,10 @@ Non-2xx responses raise typed exceptions:
109
150
  | 429 | `RateLimitedError` (`.retry_after`) |
110
151
  | 5xx | `ServerError` (after one automatic retry on 502/503/504) |
111
152
 
112
- All inherit from `FixYourDocsError`.
153
+ All inherit from `FixYourDocsError`. In consumer-side mode, `send()` may
154
+ also raise `PrivacyError` (non-public `doc_url`) or `OptedOutError`
155
+ (host opted out via its `.well-known` file) *before* any request is
156
+ posted.
113
157
 
114
158
  ## Licence
115
159
 
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fixyourdocs"
7
- version = "0.2.0"
7
+ version = "0.3.0"
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",
@@ -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.2.0"
8
+ __version__ = "0.3.0"
9
9
 
10
10
  from fixyourdocs._results import SendResult
11
11
  from fixyourdocs.client import AsyncClient, Client
@@ -16,6 +16,7 @@ from fixyourdocs.errors import (
16
16
  OptedOutError,
17
17
  PayloadTooLargeError,
18
18
  PolicyRejectedError,
19
+ PrivacyError,
19
20
  RateLimitedError,
20
21
  ServerError,
21
22
  UnsupportedMediaTypeError,
@@ -44,6 +45,7 @@ __all__ = [
44
45
  "OptedOutError",
45
46
  "PayloadTooLargeError",
46
47
  "PolicyRejectedError",
48
+ "PrivacyError",
47
49
  "RateLimitedError",
48
50
  "Report",
49
51
  "ReportBody",
@@ -17,7 +17,7 @@ from pathlib import Path
17
17
  from typing import IO, Optional
18
18
 
19
19
  from fixyourdocs import __version__
20
- from fixyourdocs._init import run_init
20
+ from fixyourdocs._init import run_init, run_init_global
21
21
  from fixyourdocs.client import Client
22
22
  from fixyourdocs.errors import FixYourDocsError
23
23
  from fixyourdocs.models import Report, ReportKind
@@ -47,7 +47,18 @@ def _build_parser() -> argparse.ArgumentParser:
47
47
  "--file",
48
48
  help=(
49
49
  "Explicit target file (skips auto-detection of AGENTS.md / "
50
- "CLAUDE.md / .cursor/rules / .github/copilot-instructions.md)."
50
+ "CLAUDE.md / .cursor/rules / .github/copilot-instructions.md). "
51
+ "With --global, overrides the default ~/.claude/CLAUDE.md."
52
+ ),
53
+ )
54
+ init_p.add_argument(
55
+ "--global",
56
+ dest="global_",
57
+ action="store_true",
58
+ help=(
59
+ "Write the consumer-side 'report stale third-party docs' block "
60
+ "to a global agent-config file (default ~/.claude/CLAUDE.md) "
61
+ "instead of the per-repo AGENTS.md block."
51
62
  ),
52
63
  )
53
64
  init_p.add_argument(
@@ -81,7 +92,13 @@ def _build_parser() -> argparse.ArgumentParser:
81
92
 
82
93
 
83
94
  def _do_init(args: argparse.Namespace, stdout: IO[str]) -> int:
84
- result = run_init(cwd=Path.cwd(), file=args.file)
95
+ if getattr(args, "global_", False):
96
+ result = run_init_global(file=args.file)
97
+ label = "global agent-config block"
98
+ else:
99
+ result = run_init(cwd=Path.cwd(), file=args.file)
100
+ label = "FixYourDocs snippet"
101
+
85
102
  if args.json:
86
103
  stdout.write(
87
104
  json.dumps(
@@ -94,15 +111,11 @@ def _do_init(args: argparse.Namespace, stdout: IO[str]) -> int:
94
111
  + "\n"
95
112
  )
96
113
  elif result.created:
97
- stdout.write(f"Created {result.path} with the FixYourDocs snippet.\n")
114
+ stdout.write(f"Created {result.path} with the {label}.\n")
98
115
  elif result.changed:
99
- stdout.write(
100
- f"Appended the FixYourDocs snippet to {result.path}.\n"
101
- )
116
+ stdout.write(f"Appended the {label} to {result.path}.\n")
102
117
  else:
103
- stdout.write(
104
- f"No changes — snippet already present in {result.path}.\n"
105
- )
118
+ stdout.write(f"No changes — {label} already present in {result.path}.\n")
106
119
  return 0
107
120
 
108
121
 
@@ -145,7 +158,6 @@ def _do_report(
145
158
  "id": result.id,
146
159
  "received_at": result.received_at.isoformat(),
147
160
  "is_duplicate": result.is_duplicate,
148
- "url": f"{api_url}/r/{result.id}",
149
161
  }
150
162
  )
151
163
  + "\n"
@@ -153,7 +165,6 @@ def _do_report(
153
165
  else:
154
166
  label = "Duplicate report" if result.is_duplicate else "Report accepted"
155
167
  stdout.write(f"{label}: {result.id}\n")
156
- stdout.write(f"View: {api_url}/r/{result.id}\n")
157
168
  return 0
158
169
 
159
170
 
@@ -0,0 +1,133 @@
1
+ """``.well-known`` opt-out discovery (consumer-side mode).
2
+
3
+ Before posting a report, the SDK can check whether the doc host has
4
+ opted out of receiving feedback by publishing
5
+ ``/.well-known/docs-feedback.json`` with ``opt_in: false`` (see §5 of the
6
+ protocol spec). This module holds the host-parsing, in-memory per-host
7
+ cache, and the decision logic that is shared between the sync and async
8
+ clients; only the actual HTTP GET differs (sync vs. ``await``), so each
9
+ client passes its already-fetched outcome into :func:`decide`.
10
+
11
+ v0 scope: opt-out check + default-hub fallback only. A custom
12
+ ``endpoint`` advertised by an ``opt_in: true`` well-known is intentionally
13
+ NOT honoured yet.
14
+ # TODO(v0.x): honour custom endpoint from well-known
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ from dataclasses import dataclass
21
+ from typing import Any, Optional
22
+ from urllib.parse import urlsplit
23
+
24
+ from fixyourdocs.errors import OptedOutError
25
+
26
+ # 24h per-host cache TTL.
27
+ CACHE_TTL_SECONDS = 24 * 60 * 60
28
+
29
+ WELL_KNOWN_PATH = "/.well-known/docs-feedback.json"
30
+ # Short timeout for the discovery probe; it must not stall a report.
31
+ DISCOVERY_TIMEOUT_SECONDS = 3.0
32
+
33
+
34
+ def host_from_doc_url(doc_url: str) -> str:
35
+ """Return the lower-cased host of ``doc_url`` (IPv6 brackets stripped)."""
36
+ return urlsplit(doc_url).hostname or ""
37
+
38
+
39
+ def well_known_url(host: str) -> str:
40
+ return f"https://{host}{WELL_KNOWN_PATH}"
41
+
42
+
43
+ @dataclass
44
+ class _CacheEntry:
45
+ opt_in: bool
46
+ since: Optional[str]
47
+ stored_at: float
48
+
49
+
50
+ class OptOutCache:
51
+ """In-memory per-host opt-out cache with a 24h TTL."""
52
+
53
+ def __init__(self) -> None:
54
+ self._entries: dict[str, _CacheEntry] = {}
55
+
56
+ def get(self, host: str) -> Optional[_CacheEntry]:
57
+ entry = self._entries.get(host)
58
+ if entry is None:
59
+ return None
60
+ if time.monotonic() - entry.stored_at >= CACHE_TTL_SECONDS:
61
+ del self._entries[host]
62
+ return None
63
+ return entry
64
+
65
+ def store(self, host: str, *, opt_in: bool, since: Optional[str] = None) -> None:
66
+ self._entries[host] = _CacheEntry(
67
+ opt_in=opt_in, since=since, stored_at=time.monotonic()
68
+ )
69
+
70
+
71
+ @dataclass
72
+ class FetchOutcome:
73
+ """Result of the discovery GET (or the lack of one).
74
+
75
+ ``status``/``data`` describe an HTTP response that was received;
76
+ ``network_error`` is ``True`` when the GET could not complete (timeout
77
+ / connection error) — that case is treated as "absent" but is NOT
78
+ cached, per spec §5.2 step 5.
79
+ """
80
+
81
+ status: Optional[int] = None
82
+ data: Optional[Any] = None
83
+ json_ok: bool = False
84
+ network_error: bool = False
85
+
86
+
87
+ def _opted_out_error(host: str, since: Optional[str]) -> OptedOutError:
88
+ return OptedOutError(
89
+ f"doc host {host} has opted out "
90
+ f"({WELL_KNOWN_PATH} opt_in:false)",
91
+ since=since,
92
+ )
93
+
94
+
95
+ def check_cache(cache: OptOutCache, host: str) -> Optional[bool]:
96
+ """Inspect the cache for ``host``.
97
+
98
+ Returns ``True`` if a cached entry says proceed, ``None`` on a miss
99
+ (caller must fetch). Raises :class:`OptedOutError` on a cached
100
+ opt-out.
101
+ """
102
+ entry = cache.get(host)
103
+ if entry is None:
104
+ return None
105
+ if entry.opt_in is False:
106
+ raise _opted_out_error(host, entry.since)
107
+ return True
108
+
109
+
110
+ def decide(cache: OptOutCache, host: str, outcome: FetchOutcome) -> None:
111
+ """Apply a freshly-fetched ``outcome`` to the cache and decision.
112
+
113
+ Caches the result (except on a network error) and raises
114
+ :class:`OptedOutError` when the host has opted out. Returns ``None``
115
+ to mean "proceed to POST".
116
+ """
117
+ if outcome.network_error:
118
+ # Treat as absent, but do NOT cache (spec §5.2 step 5).
119
+ return
120
+
121
+ if outcome.status == 200 and outcome.json_ok and isinstance(outcome.data, dict):
122
+ opt_in = outcome.data.get("opt_in")
123
+ if opt_in is False:
124
+ since = outcome.data.get("since")
125
+ since_str = since if isinstance(since, str) else None
126
+ cache.store(host, opt_in=False, since=since_str)
127
+ raise _opted_out_error(host, since_str)
128
+ # Valid JSON, not an explicit opt-out -> treat as opted in.
129
+ cache.store(host, opt_in=True)
130
+ return
131
+
132
+ # 404 / non-2xx / invalid JSON -> absent, cache as opt_in true.
133
+ cache.store(host, opt_in=True)
@@ -0,0 +1,122 @@
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
+ # Default global agent-config target for ``fixyourdocs init --global``.
19
+ GLOBAL_DEFAULT_TARGET = Path("~/.claude/CLAUDE.md")
20
+
21
+ # "Mode B" consumer-side block, sourced verbatim from the agents-md-snippet
22
+ # README. Unlike the canonical Mode A snippet in ``snippet.py`` this text is
23
+ # intentionally NOT under the cross-repo snippet-drift lock — it lives here so
24
+ # the consumer/global install path can write it to a global agent-config file.
25
+ GLOBAL_BLOCK_HEADING = "## Reporting stale third-party docs"
26
+
27
+ GLOBAL_BLOCK = """\
28
+ ## Reporting stale third-party docs
29
+
30
+ While working on this project, if you rely on a **third-party / external**
31
+ documentation page — a library, API, framework, or hosted docs site you
32
+ consulted, *not* this project's own docs, and never an internal, private,
33
+ `localhost`, or login-gated page — and you find it broken, incorrect,
34
+ outdated, or incomplete, **offer to file a structured report** via the
35
+ Docs Feedback Protocol so its maintainers can fix it for the next agent
36
+ (or human) that hits the same issue.
37
+
38
+ Rules:
39
+
40
+ - **Ask before sending.** Offer to file the report; only send once the
41
+ developer confirms.
42
+ - **Public docs only.** Only report a page served over public `https://`.
43
+ Never report `localhost`, internal, private, or login-gated URLs.
44
+ - **Never include private context.** No private code, secrets, internal
45
+ URLs, or transcript excerpts from this project.
46
+
47
+ Prefer the `file_doc_feedback` MCP tool from `@fixyourdocs/mcp-server`
48
+ if available; otherwise POST to `https://hub.fixyourdocs.io/v1/reports`
49
+ per https://docsfeedback.org.
50
+ """
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class InitResult:
55
+ path: Path
56
+ changed: bool
57
+ created: bool
58
+
59
+
60
+ def _pick_default_target(cwd: Path) -> str:
61
+ for candidate in TARGET_CANDIDATES:
62
+ if (cwd / candidate).exists():
63
+ return candidate
64
+ return "AGENTS.md"
65
+
66
+
67
+ def _with_trailing_blank_line(s: str) -> str:
68
+ if not s:
69
+ return ""
70
+ if s.endswith("\n\n"):
71
+ return s
72
+ if s.endswith("\n"):
73
+ return s + "\n"
74
+ return s + "\n\n"
75
+
76
+
77
+ def run_init(cwd: Path, file: Optional[str] = None) -> InitResult:
78
+ """Append the canonical AGENTS.md block to the chosen target file.
79
+
80
+ Idempotent: if the block heading is already present, returns
81
+ ``InitResult(changed=False)`` and leaves the file untouched.
82
+ """
83
+ target_rel = file if file is not None else _pick_default_target(cwd)
84
+ path = (cwd / target_rel).resolve()
85
+
86
+ if not path.exists():
87
+ path.write_text(SNIPPET)
88
+ return InitResult(path=path, changed=True, created=True)
89
+
90
+ current = path.read_text()
91
+ if SNIPPET_HEADING in current:
92
+ return InitResult(path=path, changed=False, created=False)
93
+
94
+ path.write_text(_with_trailing_blank_line(current) + SNIPPET)
95
+ return InitResult(path=path, changed=True, created=False)
96
+
97
+
98
+ def run_init_global(file: Optional[str] = None) -> InitResult:
99
+ """Write the "Mode B" consumer block to a global agent-config file.
100
+
101
+ Targets ``~/.claude/CLAUDE.md`` by default; pass ``file`` to override.
102
+ Idempotent: if the block heading is already present, returns
103
+ ``InitResult(changed=False)`` and leaves the file untouched. Creates
104
+ the file (and any missing parent directories) when absent, otherwise
105
+ appends.
106
+ """
107
+ if file is not None:
108
+ path = Path(file).expanduser().resolve()
109
+ else:
110
+ path = GLOBAL_DEFAULT_TARGET.expanduser().resolve()
111
+
112
+ if not path.exists():
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ path.write_text(GLOBAL_BLOCK)
115
+ return InitResult(path=path, changed=True, created=True)
116
+
117
+ current = path.read_text()
118
+ if GLOBAL_BLOCK_HEADING in current:
119
+ return InitResult(path=path, changed=False, created=False)
120
+
121
+ path.write_text(_with_trailing_blank_line(current) + GLOBAL_BLOCK)
122
+ return InitResult(path=path, changed=True, created=False)