fixyourdocs 0.2.1__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.1 → fixyourdocs-0.3.0}/PKG-INFO +45 -2
  3. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/README.md +44 -1
  4. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/pyproject.toml +1 -1
  5. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/__init__.py +3 -1
  6. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/_cli.py +23 -10
  7. fixyourdocs-0.3.0/src/fixyourdocs/_discovery.py +133 -0
  8. fixyourdocs-0.3.0/src/fixyourdocs/_init.py +122 -0
  9. fixyourdocs-0.3.0/src/fixyourdocs/_privacy.py +113 -0
  10. fixyourdocs-0.3.0/src/fixyourdocs/client.py +228 -0
  11. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/errors.py +12 -0
  12. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_async_client.py +12 -12
  13. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_cli.py +52 -0
  14. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_client.py +15 -15
  15. fixyourdocs-0.3.0/tests/test_consumer_mode.py +276 -0
  16. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_init.py +46 -1
  17. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_smoke.py +2 -1
  18. fixyourdocs-0.2.1/CHANGELOG.md +0 -20
  19. fixyourdocs-0.2.1/src/fixyourdocs/_init.py +0 -61
  20. fixyourdocs-0.2.1/src/fixyourdocs/client.py +0 -126
  21. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/scripts/check_snippet_drift.py +0 -0
  22. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/workflows/ci.yml +0 -0
  23. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/workflows/cla.yml +0 -0
  24. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/workflows/publish.yml +0 -0
  25. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/workflows/snippet-drift.yml +0 -0
  26. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.gitignore +0 -0
  27. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/AGENTS.md +0 -0
  28. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  29. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/CONTRIBUTING.md +0 -0
  30. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/LICENSE +0 -0
  31. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/_http.py +0 -0
  32. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/_results.py +0 -0
  33. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/models.py +0 -0
  34. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/py.typed +0 -0
  35. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/snippet.py +0 -0
  36. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/__init__.py +0 -0
  37. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/fixtures/full.json +0 -0
  38. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/fixtures/golden-path.json +0 -0
  39. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/fixtures/invalid.json +0 -0
  40. {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/fixtures/minimum-required.json +0 -0
  41. {fixyourdocs-0.2.1 → 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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixyourdocs
3
- Version: 0.2.1
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
@@ -71,6 +71,13 @@ pipx run fixyourdocs report \
71
71
  `.github/copilot-instructions.md` and appends to whichever exists
72
72
  (falling back to creating `AGENTS.md`). Pass `--file <path>` to override.
73
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
+
74
81
  `report` accepts `--details`, `--suggested-fix`, `--api-url`, `--token`,
75
82
  and `--json` for machine-readable output. Exit codes: `0` success,
76
83
  `2` user error, `1` transport or server error.
@@ -127,6 +134,39 @@ so the SDK exposes two ways to build a `Report`:
127
134
 
128
135
  Both produce identical wire output.
129
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
+
130
170
  ## Errors
131
171
 
132
172
  Non-2xx responses raise typed exceptions:
@@ -143,7 +183,10 @@ Non-2xx responses raise typed exceptions:
143
183
  | 429 | `RateLimitedError` (`.retry_after`) |
144
184
  | 5xx | `ServerError` (after one automatic retry on 502/503/504) |
145
185
 
146
- 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.
147
190
 
148
191
  ## Licence
149
192
 
@@ -38,6 +38,13 @@ pipx run fixyourdocs report \
38
38
  `.github/copilot-instructions.md` and appends to whichever exists
39
39
  (falling back to creating `AGENTS.md`). Pass `--file <path>` to override.
40
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
+
41
48
  `report` accepts `--details`, `--suggested-fix`, `--api-url`, `--token`,
42
49
  and `--json` for machine-readable output. Exit codes: `0` success,
43
50
  `2` user error, `1` transport or server error.
@@ -94,6 +101,39 @@ so the SDK exposes two ways to build a `Report`:
94
101
 
95
102
  Both produce identical wire output.
96
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
+
97
137
  ## Errors
98
138
 
99
139
  Non-2xx responses raise typed exceptions:
@@ -110,7 +150,10 @@ Non-2xx responses raise typed exceptions:
110
150
  | 429 | `RateLimitedError` (`.retry_after`) |
111
151
  | 5xx | `ServerError` (after one automatic retry on 502/503/504) |
112
152
 
113
- 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.
114
157
 
115
158
  ## Licence
116
159
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fixyourdocs"
7
- version = "0.2.1"
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"
@@ -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.1"
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
 
@@ -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)
@@ -0,0 +1,113 @@
1
+ """Client-side privacy guard for ``doc_url`` (consumer-side mode).
2
+
3
+ A single entry point, :func:`assert_public_doc_url`, that refuses to let
4
+ the SDK report against anything other than a public ``https://`` doc
5
+ page. It runs *before* any network call, so a rejection means no GET and
6
+ no POST happen. The clients call it when ``enforce_privacy=True`` (the
7
+ default); passing ``enforce_privacy=False`` skips it entirely (the
8
+ escape hatch for first-party / self-hosted / internal use).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ipaddress
14
+ from urllib.parse import urlsplit
15
+
16
+ from fixyourdocs.errors import PrivacyError
17
+
18
+ # Host suffixes that always denote a non-public name.
19
+ _PRIVATE_SUFFIXES = (".localhost", ".local", ".internal")
20
+
21
+
22
+ def _host_from_doc_url(doc_url: str) -> str:
23
+ """Return the lower-cased host of ``doc_url`` (IPv6 brackets stripped)."""
24
+ host = urlsplit(doc_url).hostname # already lower-cased, brackets stripped
25
+ return host or ""
26
+
27
+
28
+ def _is_non_public_ip(host: str) -> bool:
29
+ """Return ``True`` if ``host`` is an IP literal in a non-public range.
30
+
31
+ Returns ``False`` when ``host`` is not an IP literal at all (callers
32
+ fall back to hostname-based rules in that case).
33
+ """
34
+ try:
35
+ ip = ipaddress.ip_address(host)
36
+ except ValueError:
37
+ # IPv4-mapped IPv6 (e.g. ``::ffff:127.0.0.1``) parses fine above;
38
+ # but normalise just in case a caller passed a mapped form that
39
+ # ``ip_address`` exposes via ``.ipv4_mapped``.
40
+ return False
41
+
42
+ mapped = getattr(ip, "ipv4_mapped", None)
43
+ if mapped is not None:
44
+ ip = mapped
45
+
46
+ return (
47
+ ip.is_private
48
+ or ip.is_loopback
49
+ or ip.is_link_local
50
+ or ip.is_reserved
51
+ or ip.is_unspecified
52
+ )
53
+
54
+
55
+ def assert_public_doc_url(doc_url: str) -> None:
56
+ """Raise :class:`PrivacyError` unless ``doc_url`` is a public ``https`` page.
57
+
58
+ Rejection rules (any one triggers a refusal):
59
+
60
+ * scheme is not ``https``;
61
+ * host is empty / missing;
62
+ * host is ``localhost`` or ends with ``.localhost`` / ``.local`` /
63
+ ``.internal``;
64
+ * host is a bare single-label hostname (no dot) and not an IP literal;
65
+ * host is an IP literal in a non-public range (loopback, private,
66
+ link-local, reserved, unspecified, including IPv4-mapped-IPv6
67
+ forms).
68
+ """
69
+ parts = urlsplit(doc_url)
70
+ scheme = (parts.scheme or "").lower()
71
+ if scheme != "https":
72
+ raise PrivacyError(
73
+ f"doc_url scheme {scheme or '(none)'!r} is not allowed; "
74
+ "only public https:// doc pages may be reported "
75
+ "(set enforce_privacy=False to override)"
76
+ )
77
+
78
+ host = _host_from_doc_url(doc_url)
79
+ if not host:
80
+ raise PrivacyError(
81
+ f"doc_url {doc_url!r} has no host; only public https:// doc "
82
+ "pages may be reported (set enforce_privacy=False to override)"
83
+ )
84
+
85
+ # IP literals: classify with the stdlib.
86
+ try:
87
+ ipaddress.ip_address(host)
88
+ is_ip = True
89
+ except ValueError:
90
+ is_ip = False
91
+
92
+ if is_ip:
93
+ if _is_non_public_ip(host):
94
+ raise PrivacyError(
95
+ f"doc host {host} is a non-public IP address; only public "
96
+ "https:// doc pages may be reported "
97
+ "(set enforce_privacy=False to override)"
98
+ )
99
+ return
100
+
101
+ if host == "localhost" or host.endswith(_PRIVATE_SUFFIXES):
102
+ raise PrivacyError(
103
+ f"doc host {host} is a local/internal name; only public "
104
+ "https:// doc pages may be reported "
105
+ "(set enforce_privacy=False to override)"
106
+ )
107
+
108
+ if "." not in host:
109
+ raise PrivacyError(
110
+ f"doc host {host} is a bare single-label hostname; only public "
111
+ "https:// doc pages may be reported "
112
+ "(set enforce_privacy=False to override)"
113
+ )