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.
- fixyourdocs-0.3.0/CHANGELOG.md +49 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/CODE_OF_CONDUCT.md +1 -1
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/CONTRIBUTING.md +1 -1
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/PKG-INFO +47 -3
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/README.md +45 -1
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/pyproject.toml +2 -2
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/__init__.py +3 -1
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/_cli.py +23 -12
- 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.0 → fixyourdocs-0.3.0}/src/fixyourdocs/errors.py +12 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_async_client.py +12 -12
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_cli.py +52 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_client.py +15 -15
- fixyourdocs-0.3.0/tests/test_consumer_mode.py +276 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_init.py +46 -1
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/test_smoke.py +2 -1
- fixyourdocs-0.2.0/CHANGELOG.md +0 -11
- fixyourdocs-0.2.0/src/fixyourdocs/_init.py +0 -61
- fixyourdocs-0.2.0/src/fixyourdocs/client.py +0 -126
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/scripts/check_snippet_drift.py +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/workflows/ci.yml +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/workflows/cla.yml +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/workflows/publish.yml +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.github/workflows/snippet-drift.yml +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/.gitignore +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/AGENTS.md +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/LICENSE +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/_http.py +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/_results.py +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/models.py +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/py.typed +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/src/fixyourdocs/snippet.py +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/__init__.py +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/fixtures/full.json +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/fixtures/golden-path.json +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/fixtures/invalid.json +0 -0
- {fixyourdocs-0.2.0 → fixyourdocs-0.3.0}/tests/fixtures/minimum-required.json +0 -0
- {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.
|
|
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.
|
|
36
|
+
Report incidents to <hello@fixyourdocs.io>.
|
|
@@ -1,11 +1,11 @@
|
|
|
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
|
|
7
7
|
Project-URL: Specification, https://github.com/fixyourdocs/protocol
|
|
8
|
-
Author-email: FixYourDocs <hello@fixyourdocs.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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)
|