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.
- fixyourdocs-0.3.0/CHANGELOG.md +49 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/PKG-INFO +45 -2
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/README.md +44 -1
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/pyproject.toml +1 -1
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/__init__.py +3 -1
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/_cli.py +23 -10
- fixyourdocs-0.3.0/src/fixyourdocs/_discovery.py +133 -0
- fixyourdocs-0.3.0/src/fixyourdocs/_init.py +122 -0
- fixyourdocs-0.3.0/src/fixyourdocs/_privacy.py +113 -0
- fixyourdocs-0.3.0/src/fixyourdocs/client.py +228 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/errors.py +12 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_async_client.py +12 -12
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_cli.py +52 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_client.py +15 -15
- fixyourdocs-0.3.0/tests/test_consumer_mode.py +276 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_init.py +46 -1
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/test_smoke.py +2 -1
- fixyourdocs-0.2.1/CHANGELOG.md +0 -20
- fixyourdocs-0.2.1/src/fixyourdocs/_init.py +0 -61
- fixyourdocs-0.2.1/src/fixyourdocs/client.py +0 -126
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/scripts/check_snippet_drift.py +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/workflows/ci.yml +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/workflows/cla.yml +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/workflows/publish.yml +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.github/workflows/snippet-drift.yml +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/.gitignore +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/AGENTS.md +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/CONTRIBUTING.md +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/LICENSE +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/_http.py +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/_results.py +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/models.py +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/py.typed +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/src/fixyourdocs/snippet.py +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/__init__.py +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/fixtures/full.json +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/fixtures/golden-path.json +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/fixtures/invalid.json +0 -0
- {fixyourdocs-0.2.1 → fixyourdocs-0.3.0}/tests/fixtures/minimum-required.json +0 -0
- {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.
|
|
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
|
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
+
)
|