eightstatecli 0.4.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.
escli/commands/docs.py ADDED
@@ -0,0 +1,354 @@
1
+ """
2
+ escli docs — library documentation search via Context7 API.
3
+
4
+ Usage:
5
+ escli docs search <library> Search for libraries
6
+ escli docs get <library> <query> Resolve + fetch docs (one-shot)
7
+ escli docs fetch <library-id> <query> Fetch docs by library ID
8
+
9
+ Flags:
10
+ --tokens N Max tokens to return
11
+ --page N Page number (1-10)
12
+ --topic T Focus on specific topic
13
+ --format F Output format: auto|text|json|markdown
14
+ --refresh Bypass cache
15
+ """
16
+
17
+ import argparse
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import pathlib
22
+ import sys
23
+ import time
24
+
25
+ from ..services.credentials import get_key_for_service, report_key
26
+
27
+ CTX7_API = "https://context7.com/api"
28
+ CACHE_DIR = pathlib.Path(os.environ.get("ESCLI_CACHE_DIR", pathlib.Path.home() / ".escli" / "cache"))
29
+ CACHE_TTL_SEARCH = 86400 # 24h
30
+ CACHE_TTL_RESOLVE = 604800 # 7d
31
+ CACHE_TTL_DOCS = 86400 # 24h
32
+
33
+
34
+ # ── Cache ────────────────────────────────────────────────────────
35
+
36
+ def _cache_path(layer: str, key: str) -> pathlib.Path:
37
+ h = hashlib.sha256(key.encode()).hexdigest()
38
+ return CACHE_DIR / layer / f"{h}.json"
39
+
40
+
41
+ def _cache_read(layer: str, key: str, ttl: int) -> dict | None:
42
+ path = _cache_path(layer, key)
43
+ if not path.exists():
44
+ return None
45
+ try:
46
+ data = json.loads(path.read_text())
47
+ if time.time() > data.get("expires_at", 0):
48
+ path.unlink(missing_ok=True)
49
+ return None
50
+ return data.get("payload")
51
+ except (json.JSONDecodeError, OSError):
52
+ path.unlink(missing_ok=True)
53
+ return None
54
+
55
+
56
+ def _cache_write(layer: str, key: str, payload: dict, ttl: int):
57
+ path = _cache_path(layer, key)
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ envelope = {
60
+ "created_at": time.time(),
61
+ "expires_at": time.time() + ttl,
62
+ "payload": payload,
63
+ }
64
+ path.write_text(json.dumps(envelope))
65
+
66
+
67
+ # ── HTTP ─────────────────────────────────────────────────────────
68
+
69
+ def _get_api_key() -> str | None:
70
+ return get_key_for_service("context7", "CONTEXT7_API_KEY")
71
+
72
+
73
+ def _request(url: str, api_key: str | None = None) -> dict:
74
+ import httpx
75
+ headers = {"User-Agent": "escli-docs/1.0", "X-Context7-Source": "cli"}
76
+ if api_key:
77
+ headers["Authorization"] = f"Bearer {api_key}"
78
+
79
+ resp = httpx.get(url, headers=headers, timeout=30, follow_redirects=True)
80
+
81
+ # Report rate state back to gate
82
+ if api_key:
83
+ remaining = resp.headers.get("ratelimit-remaining")
84
+ limit = resp.headers.get("ratelimit-limit")
85
+ reset = resp.headers.get("ratelimit-reset")
86
+ tier = resp.headers.get("context7-quota-tier")
87
+ report_key(
88
+ "context7",
89
+ api_key[-8:],
90
+ resp.status_code,
91
+ remaining=int(remaining) if remaining else None,
92
+ limit=int(limit) if limit else None,
93
+ reset=reset,
94
+ tier=tier,
95
+ )
96
+
97
+ if resp.status_code == 429:
98
+ print(" ✗ rate limited. try again later.", file=sys.stderr)
99
+ sys.exit(1)
100
+ if resp.status_code == 404:
101
+ return {"_status": 404}
102
+ resp.raise_for_status()
103
+ return resp.json()
104
+
105
+
106
+ # ── Commands ─────────────────────────────────────────────────────
107
+
108
+ def cmd_search(args):
109
+ """Search for libraries by name."""
110
+ import urllib.parse
111
+ name = args.library_name
112
+ query = getattr(args, "query", None) or name
113
+ refresh = getattr(args, "refresh", False)
114
+ limit = getattr(args, "limit", 10) or 10
115
+
116
+ cache_key = f"search:{name.lower()}:{query.lower()}"
117
+ if not refresh:
118
+ cached = _cache_read("search", cache_key, CACHE_TTL_SEARCH)
119
+ if cached:
120
+ _print_search(cached, args, from_cache=True)
121
+ return 0
122
+
123
+ api_key = _get_api_key()
124
+ url = f"{CTX7_API}/v2/libs/search?libraryName={urllib.parse.quote(name)}&query={urllib.parse.quote(query)}"
125
+ data = _request(url, api_key)
126
+
127
+ if data.get("_status") == 404:
128
+ if args.json:
129
+ print(json.dumps({"success": False, "error": f"no libraries found for: {name}"}))
130
+ else:
131
+ print(f" ✗ no libraries found for: {name}", file=sys.stderr)
132
+ return 1
133
+
134
+ _cache_write("search", cache_key, data, CACHE_TTL_SEARCH)
135
+
136
+ # Client-side limit
137
+ if "results" in data:
138
+ data["results"] = data["results"][:limit]
139
+
140
+ _print_search(data, args)
141
+ return 0
142
+
143
+
144
+ def _print_search(data, args, from_cache=False):
145
+ results = data.get("results", [])
146
+ if args.json:
147
+ print(json.dumps({"success": True, "cached": from_cache, "results": results, "count": len(results)}))
148
+ return
149
+
150
+ if not results:
151
+ print(" No results found.", file=sys.stderr)
152
+ return
153
+
154
+ cache_label = " (cached)" if from_cache else ""
155
+ if not args.quiet:
156
+ print(f"\n Search results{cache_label}:\n", file=sys.stderr)
157
+ for r in results:
158
+ state = r.get("state", "")
159
+ stars = r.get("stars", "")
160
+ print(f" {r.get('id', ''):<40} {r.get('title', '')}", file=sys.stderr)
161
+ print(f" state={state} stars={stars} {r.get('description', '')[:80]}", file=sys.stderr)
162
+ print(file=sys.stderr)
163
+
164
+ # Pipe-friendly: TSV to stdout
165
+ if args.quiet:
166
+ for r in results:
167
+ print(f"{r.get('id', '')}\t{r.get('title', '')}\t{r.get('state', '')}")
168
+
169
+
170
+ def cmd_get(args):
171
+ """One-shot: resolve library name to ID, then fetch docs."""
172
+ import urllib.parse
173
+ name = args.library_name
174
+ query = args.query
175
+ refresh = getattr(args, "refresh", False)
176
+
177
+ # Resolve
178
+ resolve_key = f"resolve:{name.lower()}"
179
+ library_id = None
180
+ if not refresh:
181
+ cached = _cache_read("resolve", resolve_key, CACHE_TTL_RESOLVE)
182
+ if cached:
183
+ library_id = cached.get("id")
184
+
185
+ if not library_id:
186
+ api_key = _get_api_key()
187
+ url = f"{CTX7_API}/v2/libs/search?libraryName={urllib.parse.quote(name)}&query={urllib.parse.quote(query)}"
188
+ data = _request(url, api_key)
189
+
190
+ if data.get("_status") == 404:
191
+ if args.json:
192
+ print(json.dumps({"success": False, "error": f"no libraries found for: {name}"}))
193
+ else:
194
+ print(f" ✗ no libraries found for: {name}", file=sys.stderr)
195
+ return 1
196
+
197
+ results = data.get("results", [])
198
+ finalized = [r for r in results if r.get("state") == "finalized"]
199
+ pick = finalized[0] if finalized else (results[0] if results else None)
200
+
201
+ if not pick:
202
+ if args.json:
203
+ print(json.dumps({"success": False, "error": f"no results for: {name}"}))
204
+ else:
205
+ print(f" ✗ no results for: {name}", file=sys.stderr)
206
+ return 1
207
+
208
+ library_id = pick["id"]
209
+ _cache_write("resolve", resolve_key, {"id": library_id, "name": name}, CACHE_TTL_RESOLVE)
210
+
211
+ if not args.quiet:
212
+ print(f" → resolved: {library_id}", file=sys.stderr)
213
+
214
+ # Fetch docs
215
+ return _fetch_docs(library_id, query, args)
216
+
217
+
218
+ def cmd_fetch(args):
219
+ """Fetch docs directly by library ID."""
220
+ return _fetch_docs(args.library_id, args.query, args)
221
+
222
+
223
+ def _fetch_docs(library_id: str, query: str, args) -> int:
224
+ import urllib.parse
225
+ tokens = getattr(args, "tokens", None)
226
+ page = getattr(args, "page", None)
227
+ topic = getattr(args, "topic", None)
228
+ refresh = getattr(args, "refresh", False)
229
+
230
+ cache_key = f"docs:{library_id}:{query}:{tokens}:{page}:{topic}"
231
+ if not refresh:
232
+ cached = _cache_read("docs", cache_key, CACHE_TTL_DOCS)
233
+ if cached:
234
+ _print_docs(cached, args, library_id, query, from_cache=True)
235
+ return 0
236
+
237
+ api_key = _get_api_key()
238
+ url = f"{CTX7_API}/v2/context?libraryId={urllib.parse.quote(library_id)}&query={urllib.parse.quote(query)}&type=txt"
239
+
240
+ if tokens:
241
+ url += f"&tokens={tokens}"
242
+ if page:
243
+ url += f"&page={page}"
244
+ if topic:
245
+ url += f"&topic={urllib.parse.quote(topic)}"
246
+
247
+ resp = _request_raw(url, api_key)
248
+
249
+ if resp.status_code == 404:
250
+ if args.json:
251
+ print(json.dumps({"success": False, "error": f"library not found: {library_id}"}))
252
+ else:
253
+ print(f" ✗ library not found: {library_id}", file=sys.stderr)
254
+ return 1
255
+
256
+ body = resp.text
257
+ _cache_write("docs", cache_key, {"body": body}, CACHE_TTL_DOCS)
258
+ _print_docs({"body": body}, args, library_id, query)
259
+ return 0
260
+
261
+
262
+ def _request_raw(url: str, api_key: str | None = None):
263
+ import httpx
264
+ headers = {"User-Agent": "escli-docs/1.0", "X-Context7-Source": "cli"}
265
+ if api_key:
266
+ headers["Authorization"] = f"Bearer {api_key}"
267
+
268
+ resp = httpx.get(url, headers=headers, timeout=30, follow_redirects=True)
269
+
270
+ if api_key:
271
+ remaining = resp.headers.get("ratelimit-remaining")
272
+ limit = resp.headers.get("ratelimit-limit")
273
+ reset = resp.headers.get("ratelimit-reset")
274
+ tier = resp.headers.get("context7-quota-tier")
275
+ report_key(
276
+ "context7", api_key[-8:], resp.status_code,
277
+ remaining=int(remaining) if remaining else None,
278
+ limit=int(limit) if limit else None,
279
+ reset=reset, tier=tier,
280
+ )
281
+
282
+ if resp.status_code == 429:
283
+ print(" ✗ rate limited. try again later.", file=sys.stderr)
284
+ sys.exit(1)
285
+
286
+ return resp
287
+
288
+
289
+ def _print_docs(data, args, library_id, query, from_cache=False):
290
+ body = data.get("body", "")
291
+ if args.json:
292
+ print(json.dumps({
293
+ "success": True,
294
+ "cached": from_cache,
295
+ "library_id": library_id,
296
+ "query": query,
297
+ "content": body,
298
+ }))
299
+ else:
300
+ print(body)
301
+
302
+
303
+ # ── Parser ───────────────────────────────────────────────────────
304
+
305
+ def register(subparsers):
306
+ """Register the docs subcommand group."""
307
+ F = argparse.RawDescriptionHelpFormatter
308
+
309
+ docs_p = subparsers.add_parser(
310
+ "docs", aliases=["d"], help="Library documentation search (Context7)",
311
+ formatter_class=F,
312
+ epilog="""subcommands:
313
+ search <library> Search for libraries
314
+ get <library> <query> Resolve + fetch docs (one-shot)
315
+ fetch <library-id> <query> Fetch docs by library ID
316
+
317
+ examples:
318
+ escli docs search react
319
+ escli docs get next.js "app router middleware"
320
+ escli docs get react "useEffect cleanup" --tokens 5000
321
+ escli --json --quiet docs get express "error handling"
322
+ """)
323
+
324
+ docs_subs = docs_p.add_subparsers(dest="docs_command", metavar="subcommand")
325
+
326
+ # search
327
+ search_p = docs_subs.add_parser("search", aliases=["s"], help="Search for libraries")
328
+ search_p.add_argument("library_name", help="Library name to search for")
329
+ search_p.add_argument("query", nargs="?", default=None, help="Optional search query")
330
+ search_p.add_argument("--limit", type=int, default=10, help="Limit results (default: 10)")
331
+ search_p.add_argument("--refresh", action="store_true", help="Bypass cache")
332
+ search_p.set_defaults(func=cmd_search)
333
+
334
+ # get (one-shot: resolve + fetch)
335
+ get_p = docs_subs.add_parser("get", aliases=["g"], help="Resolve + fetch docs")
336
+ get_p.add_argument("library_name", help="Library name")
337
+ get_p.add_argument("query", help="Documentation query")
338
+ get_p.add_argument("--tokens", type=int, default=None, help="Max tokens to return")
339
+ get_p.add_argument("--page", type=int, default=None, help="Page number (1-10)")
340
+ get_p.add_argument("--topic", default=None, help="Focus on specific topic")
341
+ get_p.add_argument("--refresh", action="store_true", help="Bypass cache")
342
+ get_p.set_defaults(func=cmd_get)
343
+
344
+ # fetch (by library ID)
345
+ fetch_p = docs_subs.add_parser("fetch", aliases=["f"], help="Fetch docs by library ID")
346
+ fetch_p.add_argument("library_id", help="Library ID (e.g. /facebook/react)")
347
+ fetch_p.add_argument("query", help="Documentation query")
348
+ fetch_p.add_argument("--tokens", type=int, default=None, help="Max tokens to return")
349
+ fetch_p.add_argument("--page", type=int, default=None, help="Page number (1-10)")
350
+ fetch_p.add_argument("--topic", default=None, help="Focus on specific topic")
351
+ fetch_p.add_argument("--refresh", action="store_true", help="Bypass cache")
352
+ fetch_p.set_defaults(func=cmd_fetch)
353
+
354
+ return docs_p