fetchpdf 0.1.0__py3-none-any.whl

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 (64) hide show
  1. fetchpdf/__init__.py +49 -0
  2. fetchpdf/__main__.py +6 -0
  3. fetchpdf/_env.py +63 -0
  4. fetchpdf/_http.py +40 -0
  5. fetchpdf/fetch_abstract_from_doi.py +549 -0
  6. fetchpdf/fetch_metadata_from_doi.py +225 -0
  7. fetchpdf/fetchpdf.py +7339 -0
  8. fetchpdf/retrieval/__init__.py +36 -0
  9. fetchpdf/retrieval/_util.py +150 -0
  10. fetchpdf/retrieval/artifact.py +87 -0
  11. fetchpdf/retrieval/backends/__init__.py +109 -0
  12. fetchpdf/retrieval/backends/claude_cli.py +111 -0
  13. fetchpdf/retrieval/backends/openrouter.py +244 -0
  14. fetchpdf/retrieval/cache.py +123 -0
  15. fetchpdf/retrieval/chunk.py +195 -0
  16. fetchpdf/retrieval/classify.py +110 -0
  17. fetchpdf/retrieval/context.py +69 -0
  18. fetchpdf/retrieval/corresponding.py +349 -0
  19. fetchpdf/retrieval/engine.py +666 -0
  20. fetchpdf/retrieval/extract_images.py +593 -0
  21. fetchpdf/retrieval/fulltext_scan.py +472 -0
  22. fetchpdf/retrieval/goals.py +145 -0
  23. fetchpdf/retrieval/http.py +669 -0
  24. fetchpdf/retrieval/identifiers.py +149 -0
  25. fetchpdf/retrieval/ladder.json +362 -0
  26. fetchpdf/retrieval/linked_artifacts.py +144 -0
  27. fetchpdf/retrieval/llm_adjudicate.py +278 -0
  28. fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
  29. fetchpdf/retrieval/normalize.py +301 -0
  30. fetchpdf/retrieval/pdf_identity.py +533 -0
  31. fetchpdf/retrieval/pdf_text.py +242 -0
  32. fetchpdf/retrieval/provenance.py +185 -0
  33. fetchpdf/retrieval/ratelimit.py +113 -0
  34. fetchpdf/retrieval/repository_waf.py +152 -0
  35. fetchpdf/retrieval/request_drafts.py +304 -0
  36. fetchpdf/retrieval/resolve.py +481 -0
  37. fetchpdf/retrieval/sources/__init__.py +15 -0
  38. fetchpdf/retrieval/sources/arxiv.py +102 -0
  39. fetchpdf/retrieval/sources/core_text.py +64 -0
  40. fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
  41. fetchpdf/retrieval/sources/epmc.py +133 -0
  42. fetchpdf/retrieval/sources/landing.py +34 -0
  43. fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
  44. fetchpdf/retrieval/sources/pmc.py +59 -0
  45. fetchpdf/retrieval/sources/preprint.py +101 -0
  46. fetchpdf/retrieval/sources/publisher_html.py +154 -0
  47. fetchpdf/retrieval/sources/supplements.py +183 -0
  48. fetchpdf/retrieval/supplement_atypon.py +269 -0
  49. fetchpdf/retrieval/supplement_dspace.py +187 -0
  50. fetchpdf/retrieval/supplement_graph.py +842 -0
  51. fetchpdf/retrieval/supplement_index.py +820 -0
  52. fetchpdf/retrieval/supplement_pmc.py +351 -0
  53. fetchpdf/retrieval/supplement_publishers.py +374 -0
  54. fetchpdf/retrieval/supplementary.py +1770 -0
  55. fetchpdf/retrieval/tiers.py +213 -0
  56. fetchpdf/retrieval/to_markdown.py +661 -0
  57. fetchpdf/retrieval/validate.py +368 -0
  58. fetchpdf/retrieval/verify.py +149 -0
  59. fetchpdf-0.1.0.dist-info/METADATA +1392 -0
  60. fetchpdf-0.1.0.dist-info/RECORD +64 -0
  61. fetchpdf-0.1.0.dist-info/WHEEL +5 -0
  62. fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
  63. fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. fetchpdf-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,36 @@
1
+ """Format-prioritized retrieval.
2
+
3
+ The default retrieval path in ``fetch_pdf`` is PDF-shaped: it walks a
4
+ fixed list of sources and accepts the first thing with ``%PDF`` magic bytes.
5
+ That is the right default for a human who wants a paper to read.
6
+
7
+ It is the wrong default for feeding a table-extraction pipeline, because what
8
+ breaks there is not character accuracy but row/column/header association -- and
9
+ that association only survives losslessly in markup. This package inverts the
10
+ loop: format tiers outer, sources inner, so a JATS copy from a worse-ranked
11
+ source beats a PDF from a better-ranked one.
12
+
13
+ This package also holds the second, independent opt-in feature that grew out of
14
+ the same machinery: ``supplementary``, reached by ``--pull-supplementary``, which
15
+ fetches everything the authors deposited *alongside* the paper. It is not a tier.
16
+ The tier walk is winner-take-all by design -- one best representation of the full
17
+ text -- and "what else is there" is a set, not a winner, so it runs as a pass
18
+ after retrieval rather than as a rung inside it. It shares this package's HTTP
19
+ client, rate limiter and identifier resolver, which is why it lives here.
20
+
21
+ Nothing in here runs unless one of the opt-in flags is set. With none of them,
22
+ the existing chain executes byte-for-byte as before.
23
+ """
24
+
25
+ from .tiers import Tier, Ladder, load_ladder
26
+ from .identifiers import IdentifierSet, ResolutionChain
27
+ from .artifact import Artifact
28
+
29
+ __all__ = [
30
+ "Tier",
31
+ "Ladder",
32
+ "load_ladder",
33
+ "IdentifierSet",
34
+ "ResolutionChain",
35
+ "Artifact",
36
+ ]
@@ -0,0 +1,150 @@
1
+ """Small helpers that more than one module in this package needs.
2
+
3
+ Each of these existed in two to four copies before. That is mostly harmless
4
+ duplication -- except for `localname`, where it was not: both copies need the
5
+ non-string guard for lxml comment and processing-instruction nodes, whose `.tag`
6
+ is a *callable* rather than a name. A copy written without that guard raised
7
+ TypeError on every real publisher page, which is how T2 validation was broken
8
+ until an integration run caught it. One definition, one guard.
9
+ """
10
+
11
+ import html as _html
12
+ import os
13
+ import unicodedata
14
+ from datetime import datetime, timezone
15
+ from typing import Optional
16
+
17
+ #: Two titles for the same work are the same title at 0.93 and are different
18
+ #: works well below it. The margin is not delicate: a Dataverse deposit and its
19
+ #: own article score 0.99, an unrelated one 0.12. Anything in between is not a
20
+ #: near-miss worth arguing over. `supplement_graph` still carries its own copy
21
+ #: of this number and its own `_squash`; they predate these and have not been
22
+ #: migrated, so the two are free to drift. Anyone touching either should move
23
+ #: that caller over rather than adding a third.
24
+ TITLE_MATCH_RATIO = 0.93
25
+
26
+
27
+ def localname(tag) -> str:
28
+ """An element tag without its namespace.
29
+
30
+ TEI is namespaced, JATS usually is not, and lxml hands back comments and
31
+ processing instructions whose `.tag` is a function -- hence the isinstance
32
+ check, which is the whole reason this lives in one place.
33
+ """
34
+ if not isinstance(tag, str):
35
+ return ""
36
+ return tag.rsplit("}", 1)[-1].lower() if "}" in tag else tag.lower()
37
+
38
+
39
+ def as_int(value, default: int = 0) -> int:
40
+ """int(value) for values that arrive from JSON as strings, or as None."""
41
+ try:
42
+ return int(value)
43
+ except (TypeError, ValueError):
44
+ return default
45
+
46
+
47
+ def iso(timestamp: float) -> str:
48
+ """A UTC ISO-8601 stamp. Used in every audit record this package writes."""
49
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
50
+
51
+
52
+ def unlink(path: Optional[str]) -> None:
53
+ """Delete a path, tolerating its absence. Cleanup must never raise."""
54
+ if not path:
55
+ return
56
+ try:
57
+ os.unlink(path)
58
+ except OSError:
59
+ pass
60
+
61
+
62
+ def squash(text: str) -> str:
63
+ """Lowercased alphanumerics only, so punctuation and casing differences
64
+ between a deposit title and a journal title cannot break the match.
65
+
66
+ Dropping *everything* non-alphanumeric is also what makes this usable
67
+ against text extracted from a PDF page: a title broken across a line, or
68
+ hyphenated at the break, squashes to the same string as the title it came
69
+ from. That is load-bearing for `pdf_identity`, not incidental.
70
+
71
+ The NFKD pass is load-bearing for the same caller and for a reason that is
72
+ invisible until it bites: PDF text extraction hands back the *ligature*
73
+ glyphs a typesetter used, so a page reading "identification" arrives as
74
+ "identification" -- one character, U+FB01. Without decomposition that title
75
+ silently fails to match itself, and under reject-always a silent non-match
76
+ is a deleted file. Decomposing first turns the ligature back into "fi". It
77
+ also strips accents to their base letters, so "café" and "cafe" agree.
78
+
79
+ Kept as `str.isalnum()` rather than `[^a-z0-9]`, which is what this was:
80
+ that character class erases every non-Latin script outright. A Cyrillic,
81
+ Greek or CJK title squashed to the empty string, and an empty title matches
82
+ nothing -- so under reject-always every paper with a non-Latin title and no
83
+ DOI printed on the page would have been deleted as somebody else's.
84
+ """
85
+ # Unescaped first because titles arrive from Crossref carrying HTML
86
+ # entities -- a real title reads "salience & message framing" in the
87
+ # metadata and "salience & message framing" on the page, and the stray
88
+ # "amp" splits the match straight down the middle.
89
+ unescaped = _html.unescape(text or "")
90
+ decomposed = unicodedata.normalize("NFKD", unescaped).lower()
91
+ return "".join(ch for ch in decomposed if ch.isalnum())
92
+
93
+
94
+ def matched_fraction(needle: str, haystack: str, min_block: int = 4) -> float:
95
+ """How much of `needle` appears in `haystack`, across ALL matching runs.
96
+
97
+ The right question to ask when matching a title against a page of extracted
98
+ text, and `SequenceMatcher.ratio()` is the wrong one: ratio is symmetric and
99
+ length-sensitive, so a 90-character title compared against 4,000 characters
100
+ of page scores near zero however perfectly the title appears on it.
101
+
102
+ Deliberately gap-tolerant. A single substituted character in the middle of a
103
+ title halves the longest contiguous run while leaving the title obviously
104
+ intact -- measured on a real record, "V717F β-Amyloid Precursor Protein"
105
+ typeset as "V717F b-Amyloid Precursor Protein" scores 0.62 by longest run
106
+ and 0.99 by matched total. Greek letters set in a Latin face are pervasive
107
+ in the life sciences, so that is not an edge case, it is Tuesday.
108
+
109
+ `min_block` is what keeps this honest: without it, a long page of prose
110
+ supplies a matching character somewhere for nearly every letter of any
111
+ title, and everything scores high. Only runs of at least `min_block`
112
+ characters count, so the score still means "this page contains these
113
+ phrases", not "this page contains these letters".
114
+
115
+ Both sides are assumed already squashed.
116
+ """
117
+ import difflib
118
+
119
+ if not needle or not haystack:
120
+ return 0.0
121
+ matcher = difflib.SequenceMatcher(None, needle, haystack, autojunk=False)
122
+ total = sum(b.size for b in matcher.get_matching_blocks() if b.size >= min_block)
123
+ return min(1.0, total / float(len(needle)))
124
+
125
+
126
+ def normalise_doi(doi: str) -> str:
127
+ """A DOI stripped of the several prefixes it arrives wearing."""
128
+ doi = (doi or "").strip().lower()
129
+ for prefix in ("doi:", "https://doi.org/", "http://doi.org/", "doi.org/",
130
+ "https://dx.doi.org/", "http://dx.doi.org/", "dx.doi.org/"):
131
+ if doi.startswith(prefix):
132
+ doi = doi[len(prefix):]
133
+ return doi
134
+
135
+
136
+ def titles_match(a: str, b: str, ratio: float = TITLE_MATCH_RATIO) -> bool:
137
+ """Whether two titles name the same work.
138
+
139
+ Containment first -- it is cheap and it is the common case, since one side
140
+ is routinely a subtitle-trimmed or prefix-wrapped form of the other. The
141
+ difflib ratio is only for the spelling-variant tail that containment drops.
142
+ """
143
+ import difflib
144
+
145
+ left, right = squash(a), squash(b)
146
+ if not left or not right:
147
+ return False
148
+ if left in right or right in left:
149
+ return True
150
+ return difflib.SequenceMatcher(None, left, right).ratio() >= ratio
@@ -0,0 +1,87 @@
1
+ """What a source returns, and what a validator says about it.
2
+
3
+ Kept in its own module so sources, validators, the engine and the provenance
4
+ writer can all share these types without importing each other.
5
+ """
6
+
7
+ import hashlib
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from .tiers import TIER_EXTENSIONS, Tier
12
+
13
+
14
+ @dataclass
15
+ class ValidationResult:
16
+ """The outcome of a tier gate, and enough detail to say WHY.
17
+
18
+ `reason` is populated on failure and on success alike: provenance has to
19
+ record which checks passed, not merely that some unnamed check did.
20
+ """
21
+
22
+ ok: bool
23
+ reason: str = ""
24
+ checks_passed: List[str] = field(default_factory=list)
25
+ n_chars: int = 0
26
+ n_tables: int = 0
27
+ n_footnotes: int = 0
28
+ n_populated_cells: int = 0
29
+
30
+ def to_dict(self) -> dict:
31
+ return {
32
+ "ok": self.ok,
33
+ "reason": self.reason,
34
+ "checks_passed": list(self.checks_passed),
35
+ "n_chars": self.n_chars,
36
+ "n_tables": self.n_tables,
37
+ "n_footnotes": self.n_footnotes,
38
+ "n_populated_cells": self.n_populated_cells,
39
+ }
40
+
41
+ @classmethod
42
+ def failure(cls, reason: str, **kwargs) -> "ValidationResult":
43
+ return cls(ok=False, reason=reason, **kwargs)
44
+
45
+
46
+ @dataclass
47
+ class Artifact:
48
+ """One retrieved candidate, before or after validation.
49
+
50
+ `tier` is what the source expected to produce. The engine overwrites it with
51
+ the tier the classifier assigns from the bytes themselves -- a source that
52
+ advertises XML and returns an HTML error page is reclassified, not trusted.
53
+ """
54
+
55
+ content: bytes
56
+ tier: Tier
57
+ source: str
58
+ url: str
59
+ http_status: int
60
+ served_content_type: str = ""
61
+ identifier_used: str = ""
62
+ license: Optional[str] = None
63
+ extension: Optional[str] = None
64
+ validation: Optional[ValidationResult] = None
65
+ tables_unavailable: bool = False
66
+ declared_tier: Optional[Tier] = None # what the source claimed, if reclassified
67
+ normalization_failures: List[str] = field(default_factory=list)
68
+ extra: Dict[str, Any] = field(default_factory=dict)
69
+
70
+
71
+
72
+ @property
73
+ def suffix(self) -> str:
74
+ return self.extension or TIER_EXTENSIONS[self.tier]
75
+
76
+ @property
77
+ def content_hash(self) -> str:
78
+ return "sha256:" + hashlib.sha256(self.content).hexdigest()
79
+
80
+ def __len__(self):
81
+ return len(self.content)
82
+
83
+ def __repr__(self):
84
+ return (
85
+ f"<Artifact {self.source} {self.tier.name} "
86
+ f"{len(self.content)}B status={self.http_status}>"
87
+ )
@@ -0,0 +1,109 @@
1
+ """Backends for the retrieval agent: one contract, two very different sandboxes.
2
+
3
+ The agent's job is the same either way -- read the paper, work out which
4
+ supplementary files and datasets we do not already have, and get them -- but
5
+ what it is *allowed to do* differs so much between the two that the difference
6
+ is the interesting part.
7
+
8
+ MEASURED 2026-08-22, against Claude Code 2.1.240, because the containment story
9
+ in the design turned out to be wrong and only testing found it:
10
+
11
+ * `--allowed-tools` GRANTS permission, it does not restrict the tool set. A
12
+ subprocess run with `--allowed-tools "Bash(echo:*)"` happily ran
13
+ `cat ../outside_canary.txt` and returned the contents. So did
14
+ `--allowed-tools WebFetch`. Both with and without
15
+ `--permission-mode bypassPermissions`.
16
+ * `--disallowed-tools` DOES remove tools, and is the only lever that worked.
17
+ * A default subprocess inherits the operator's own MCP servers. On the
18
+ machine this was written on that meant Gmail, Google Drive, Google Calendar
19
+ and a brokerage account were in scope for a subprocess whose job is
20
+ downloading spreadsheets. `--strict-mcp-config` with an empty config
21
+ removes them.
22
+
23
+ The consequence: the CLI backend gets WebFetch and nothing else -- no shell, no
24
+ file tools, no MCP -- and therefore *cannot write a file*. So it does not
25
+ download. It navigates, which is the part that actually needs a model (open the
26
+ landing page, find the real file link, decide whether this deposit belongs to
27
+ this paper), and reports URLs. fetchpdf does the transfer, through the same
28
+ capped, hashed, deduplicated path every other provider uses.
29
+
30
+ The OpenRouter backend is the opposite: there is no general-purpose agent
31
+ harness to lock down, because the two tools it can call are ours. `download`
32
+ writes inside the staging directory by construction and enforces the byte cap
33
+ itself. That backend really does download.
34
+
35
+ Both return the same receipt, and the caller cannot tell which one ran by
36
+ looking at the output directory. That is the point.
37
+ """
38
+
39
+ from typing import Optional
40
+
41
+ #: The receipt one entry at a time. Keys a backend may set:
42
+ #:
43
+ #: url the direct file URL, or the deposit landing page
44
+ #: kind "supplement" (the paper's own SI) or "dataset" (a deposit)
45
+ #: deposit the repository landing page this file came from, if any
46
+ #: name the filename to record it under, when the URL does not say
47
+ #: why one short line: why this belongs to THIS paper
48
+ #: saved_as staging-relative path, set only by a backend that downloads
49
+ RECEIPT_KEYS = ("url", "kind", "deposit", "name", "why", "saved_as")
50
+
51
+ KIND_SUPPLEMENT = "supplement"
52
+ KIND_DATASET = "dataset"
53
+
54
+ #: Registered backends, resolved by `--llm-backend`.
55
+ BACKENDS = ("claude-cli", "openrouter")
56
+ DEFAULT_BACKEND = "claude-cli"
57
+
58
+
59
+ def get_backend(name: Optional[str] = None):
60
+ """A backend instance by name, or None when the name is unknown.
61
+
62
+ Imported lazily so a run that never asks for an agent never imports one.
63
+ """
64
+ name = (name or DEFAULT_BACKEND).strip().lower()
65
+ if name == "claude-cli":
66
+ from .claude_cli import ClaudeCliBackend
67
+ return ClaudeCliBackend()
68
+ if name == "openrouter":
69
+ from .openrouter import OpenRouterBackend
70
+ return OpenRouterBackend()
71
+ return None
72
+
73
+
74
+ def normalize_receipt(raw, staging: str = "") -> list:
75
+ """Whatever the model returned, reduced to entries we can act on.
76
+
77
+ Defensive by policy rather than by taste: this is the one place a model's
78
+ free-form output crosses into code that downloads things. An entry without
79
+ a usable URL or staged file is dropped rather than guessed at, and a
80
+ `saved_as` that escapes the staging directory is dropped too -- a path is
81
+ the one field a model could use to reach outside its sandbox.
82
+ """
83
+ import os
84
+
85
+ if not isinstance(raw, list):
86
+ return []
87
+ staging_real = os.path.realpath(staging) if staging else ""
88
+ entries = []
89
+ for item in raw:
90
+ if not isinstance(item, dict):
91
+ continue
92
+ entry = {k: item.get(k) for k in RECEIPT_KEYS if item.get(k)}
93
+ saved = entry.get("saved_as")
94
+ if saved:
95
+ candidate = os.path.realpath(os.path.join(staging, str(saved)))
96
+ inside = (staging_real and
97
+ (candidate == staging_real or
98
+ candidate.startswith(staging_real + os.sep)))
99
+ if not inside or not os.path.isfile(candidate):
100
+ entry.pop("saved_as", None)
101
+ else:
102
+ entry["saved_as"] = candidate
103
+ url = str(entry.get("url") or "")
104
+ if not entry.get("saved_as") and not url.lower().startswith(("http://", "https://")):
105
+ continue
106
+ kind = str(entry.get("kind") or "").lower()
107
+ entry["kind"] = KIND_DATASET if kind.startswith("data") else KIND_SUPPLEMENT
108
+ entries.append(entry)
109
+ return entries
@@ -0,0 +1,111 @@
1
+ """The retrieval agent on the local Claude Code CLI, locked down to WebFetch.
2
+
3
+ Reuses `llm_adjudicate`'s subprocess plumbing -- `find_cli`, `run_cli`,
4
+ `unwrap_result`, `_warn_once` -- so there is one place that knows about the
5
+ Windows .cmd shim, the stdin feed and the JSON envelope.
6
+
7
+ WHY IT DOES NOT DOWNLOAD. See `backends/__init__.py` for the measurements: in
8
+ this CLI, `--allowed-tools` grants and does not restrict, and a default
9
+ subprocess inherits the operator's MCP servers. The only configuration that
10
+ verifiably contained the agent removes every file and shell tool -- which also
11
+ removes its ability to save anything. That is a fair trade. Navigating is the
12
+ part that needs a model; transferring bytes under a cap with a hash is the part
13
+ fetchpdf already does better than a shell command would.
14
+
15
+ The lockdown is a DENYLIST, and denylists rot: a tool added to Claude Code in a
16
+ later release is allowed here until someone adds it below. The two structural
17
+ guards are what this actually rests on -- no MCP config, no settings sources --
18
+ plus the fact that a WebFetch-only agent has nothing to write to.
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import tempfile
24
+ from typing import List, Optional
25
+
26
+ from ..llm_adjudicate import (
27
+ DEFAULT_MODEL,
28
+ _warn_once,
29
+ find_cli,
30
+ run_cli,
31
+ unwrap_result,
32
+ )
33
+ from . import normalize_receipt
34
+
35
+ #: The one tool it needs, and every built-in that could read the operator's
36
+ #: disk, spawn another agent, or reach a connected service. Verified against
37
+ #: Claude Code 2.1.240 by asking a locked-down subprocess to list its own
38
+ #: tools: it answered "WebFetch" and nothing else.
39
+ ALLOWED_TOOLS = "WebFetch"
40
+ DISALLOWED_TOOLS = ",".join((
41
+ "Bash", "Read", "Write", "Edit", "NotebookEdit", "Glob", "Grep",
42
+ "Agent", "Skill", "Workflow", "SendMessage", "ListAgents",
43
+ "TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", "TaskUpdate",
44
+ "WebSearch", "Monitor", "ScheduleWakeup", "ReportFindings", "ToolSearch",
45
+ "CronCreate", "CronDelete", "CronList", "DesignSync",
46
+ "EnterWorktree", "ExitWorktree", "PushNotification", "RemoteTrigger",
47
+ "ListMcpResourcesTool", "ReadMcpResourceTool", "ReadMcpResourceDirTool",
48
+ ))
49
+
50
+ #: Enough turns to open a landing page, follow it to a file listing, and
51
+ #: answer. Measured: a single OSF node lookup takes 2. The cap IS enforced --
52
+ #: a run that exceeds it comes back is_error with no result at all, which is
53
+ #: why this is generous rather than tight.
54
+ MAX_TURNS = 12
55
+
56
+ #: Wall clock for the whole conversation. The adjudicator's 60s is sized for
57
+ #: one classification; this one makes several network round trips.
58
+ TIMEOUT_SECONDS = 300
59
+
60
+
61
+ class ClaudeCliBackend:
62
+ """Navigates with WebFetch and reports URLs. Never writes a file."""
63
+
64
+ name = "claude-cli"
65
+ downloads = False
66
+
67
+ def available(self):
68
+ """`(ok, detail)`. Absence is a reason, never an exception."""
69
+ if find_cli() is None:
70
+ return False, ("Claude Code CLI not found on PATH; install it or "
71
+ "use --llm-backend openrouter")
72
+ return True, "claude"
73
+
74
+ def run(self, brief: str, staging: str, model: str = DEFAULT_MODEL,
75
+ log=None) -> Optional[List[dict]]:
76
+ cli_path = find_cli()
77
+ if cli_path is None:
78
+ ok, detail = self.available()
79
+ _warn_once(detail, log)
80
+ return None
81
+
82
+ with tempfile.TemporaryDirectory() as empty_config_dir:
83
+ config = os.path.join(empty_config_dir, "no_mcp.json")
84
+ with open(config, "w", encoding="utf-8") as handle:
85
+ json.dump({"mcpServers": {}}, handle)
86
+
87
+ stdout = run_cli(
88
+ brief,
89
+ cli_path,
90
+ model=model,
91
+ timeout=TIMEOUT_SECONDS,
92
+ allowed_tools=ALLOWED_TOOLS,
93
+ max_turns=MAX_TURNS,
94
+ extra_argv=[
95
+ # Structural, not a denylist: without these the subprocess
96
+ # inherits every MCP server the operator has connected.
97
+ "--strict-mcp-config", "--mcp-config", config,
98
+ "--setting-sources", "",
99
+ "--disallowed-tools", DISALLOWED_TOOLS,
100
+ # Nothing can prompt in print mode, so an un-preapproved
101
+ # WebFetch would simply be denied and the agent would
102
+ # report nothing. The tool set above is the boundary.
103
+ "--permission-mode", "bypassPermissions",
104
+ ],
105
+ cwd=staging,
106
+ log=log,
107
+ )
108
+
109
+ if stdout is None:
110
+ return None
111
+ return normalize_receipt(unwrap_result(stdout, expect=list), staging)