agentproc 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {agentproc-0.4.0 → agentproc-0.4.2}/PKG-INFO +1 -1
  2. {agentproc-0.4.0 → agentproc-0.4.2}/pyproject.toml +6 -1
  3. agentproc-0.4.2/src/agentproc/hub.py +656 -0
  4. agentproc-0.4.2/src/agentproc/hub_data/README.md +134 -0
  5. agentproc-0.4.2/src/agentproc/hub_data/_shared/README.md +52 -0
  6. agentproc-0.4.2/src/agentproc/hub_data/_shared/stream_utils.js +130 -0
  7. agentproc-0.4.2/src/agentproc/hub_data/_shared/stream_utils.py +161 -0
  8. agentproc-0.4.2/src/agentproc/hub_data/agy/README.md +114 -0
  9. agentproc-0.4.2/src/agentproc/hub_data/agy/bridge.js +87 -0
  10. agentproc-0.4.2/src/agentproc/hub_data/agy/bridge.py +75 -0
  11. agentproc-0.4.2/src/agentproc/hub_data/agy/profile.yaml +27 -0
  12. agentproc-0.4.2/src/agentproc/hub_data/claude-code/README.md +132 -0
  13. agentproc-0.4.2/src/agentproc/hub_data/claude-code/bridge.js +65 -0
  14. agentproc-0.4.2/src/agentproc/hub_data/claude-code/bridge.py +76 -0
  15. agentproc-0.4.2/src/agentproc/hub_data/claude-code/profile.yaml +26 -0
  16. agentproc-0.4.2/src/agentproc/hub_data/codebuddy/README.md +108 -0
  17. agentproc-0.4.2/src/agentproc/hub_data/codebuddy/bridge.js +60 -0
  18. agentproc-0.4.2/src/agentproc/hub_data/codebuddy/bridge.py +70 -0
  19. agentproc-0.4.2/src/agentproc/hub_data/codebuddy/profile.yaml +23 -0
  20. agentproc-0.4.2/src/agentproc/hub_data/codex/README.md +118 -0
  21. agentproc-0.4.2/src/agentproc/hub_data/codex/bridge.js +54 -0
  22. agentproc-0.4.2/src/agentproc/hub_data/codex/bridge.py +59 -0
  23. agentproc-0.4.2/src/agentproc/hub_data/codex/profile.yaml +25 -0
  24. agentproc-0.4.2/src/agentproc/hub_data/cursor/README.md +141 -0
  25. agentproc-0.4.2/src/agentproc/hub_data/cursor/bridge.js +83 -0
  26. agentproc-0.4.2/src/agentproc/hub_data/cursor/bridge.py +119 -0
  27. agentproc-0.4.2/src/agentproc/hub_data/cursor/profile.yaml +43 -0
  28. agentproc-0.4.2/src/agentproc/hub_data/echo-agent/README.md +50 -0
  29. agentproc-0.4.2/src/agentproc/hub_data/echo-agent/bridge.js +12 -0
  30. agentproc-0.4.2/src/agentproc/hub_data/echo-agent/bridge.py +21 -0
  31. agentproc-0.4.2/src/agentproc/hub_data/echo-agent/bridge.sh +4 -0
  32. agentproc-0.4.2/src/agentproc/hub_data/echo-agent/profile.yaml +19 -0
  33. agentproc-0.4.2/src/agentproc/hub_data/gemini-cli/README.md +134 -0
  34. agentproc-0.4.2/src/agentproc/hub_data/gemini-cli/bridge.js +74 -0
  35. agentproc-0.4.2/src/agentproc/hub_data/gemini-cli/bridge.py +91 -0
  36. agentproc-0.4.2/src/agentproc/hub_data/gemini-cli/profile.yaml +28 -0
  37. agentproc-0.4.2/src/agentproc/hub_data/qwen-code/README.md +93 -0
  38. agentproc-0.4.2/src/agentproc/hub_data/qwen-code/bridge.js +69 -0
  39. agentproc-0.4.2/src/agentproc/hub_data/qwen-code/bridge.py +78 -0
  40. agentproc-0.4.2/src/agentproc/hub_data/qwen-code/profile.yaml +26 -0
  41. agentproc-0.4.2/src/agentproc/hub_data/recursive/README.md +156 -0
  42. agentproc-0.4.2/src/agentproc/hub_data/recursive/bridge.js +330 -0
  43. agentproc-0.4.2/src/agentproc/hub_data/recursive/bridge.py +357 -0
  44. agentproc-0.4.2/src/agentproc/hub_data/recursive/profile.yaml +71 -0
  45. {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc.egg-info/PKG-INFO +1 -1
  46. agentproc-0.4.2/src/agentproc.egg-info/SOURCES.txt +55 -0
  47. {agentproc-0.4.0 → agentproc-0.4.2}/tests/test_hub.py +169 -37
  48. agentproc-0.4.0/src/agentproc/hub.py +0 -505
  49. agentproc-0.4.0/src/agentproc.egg-info/SOURCES.txt +0 -14
  50. {agentproc-0.4.0 → agentproc-0.4.2}/setup.cfg +0 -0
  51. {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc/__init__.py +0 -0
  52. {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc/cli.py +0 -0
  53. {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc/runner.py +0 -0
  54. {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc.egg-info/dependency_links.txt +0 -0
  55. {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc.egg-info/entry_points.txt +0 -0
  56. {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc.egg-info/top_level.txt +0 -0
  57. {agentproc-0.4.0 → agentproc-0.4.2}/tests/test_agentproc.py +0 -0
  58. {agentproc-0.4.0 → agentproc-0.4.2}/tests/test_conformance.py +0 -0
  59. {agentproc-0.4.0 → agentproc-0.4.2}/tests/test_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentproc
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: AgentProc Protocol SDK + CLI — connect any Agent CLI to a messaging platform
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jeffkit/agentproc
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentproc"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "AgentProc Protocol SDK + CLI — connect any Agent CLI to a messaging platform"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -22,3 +22,8 @@ Issues = "https://github.com/jeffkit/agentproc/issues"
22
22
 
23
23
  [tool.setuptools.packages.find]
24
24
  where = ["src"]
25
+
26
+ # The bundled hub/ profiles (copied into src/agentproc/hub_data/ by
27
+ # scripts/prepare_hub.py before build) so `hub run`/`hub list` work offline.
28
+ [tool.setuptools.package-data]
29
+ agentproc = ["hub_data/**/*", "hub_data/*"]
@@ -0,0 +1,656 @@
1
+ """Hub client — fetch and manage profile directories from the official Hub.
2
+
3
+ The Hub lives at https://github.com/jeffkit/agentproc/tree/main/hub/
4
+
5
+ Resolution order (so `hub run` / `hub list` work with zero network in the
6
+ common case, and stay usable where GitHub itself is unreachable, e.g. China):
7
+
8
+ 1. Bundled copy — the entire hub/ directory is shipped inside this package
9
+ (at ``<pkg>/hub_data/``). ``hub run`` and ``hub list`` read from it
10
+ directly. No network. This is the default and what most users hit.
11
+ 2. jsDelivr CDN — for ``--refresh`` or a profile newer than the installed
12
+ CLI: files come from cdn.jsdelivr.net (Fastly CDN, not GitHub's
13
+ rate-limited API), and the directory listing from jsDelivr's data API.
14
+ jsDelivr is reachable in regions where raw.githubusercontent.com is not.
15
+
16
+ Remote-fetched profiles are cached at ~/.agentproc/cache/hub/<name>/ with a
17
+ 24-hour TTL; the shared ``_shared/`` bridge helpers are cached alongside at
18
+ ~/.agentproc/cache/hub/_shared/ (the bridge scripts import them via a
19
+ sibling path).
20
+
21
+ Public API:
22
+ HUB_REPO — the github repo id ("jeffkit/agentproc")
23
+ HUB_REF — the git ref to fetch from ("main")
24
+ HUB_CACHE_TTL_SECS — default 24 hours
25
+ cache_dir(name) — Path to the local cache directory for a profile
26
+ fetch_profile(name, refresh=False, on_log=None) -> Path
27
+ list_profiles(refresh=False, on_log=None) -> List[Dict[str,str]]
28
+ show_readme(name, refresh=False, on_log=None) -> str
29
+ install_profile(name, target_dir, refresh=False, on_log=None) -> Path
30
+
31
+ All network access is via urllib (stdlib). Zero dependencies.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import os
38
+ import shutil
39
+ import time
40
+ import urllib.error
41
+ import urllib.request
42
+ from pathlib import Path
43
+ from typing import Any, Callable, Dict, List, Optional
44
+
45
+ HUB_REPO = "jeffkit/agentproc"
46
+ HUB_REF = "main"
47
+ HUB_CACHE_TTL_SECS = 24 * 60 * 60 # 24 hours
48
+
49
+ # jsDelivr mirrors the GitHub repo on a global CDN (Fastly), reachable where
50
+ # raw.githubusercontent.com / api.github.com are not. No token, no 60/hr limit.
51
+ JSDELIVR_RAW = "https://cdn.jsdelivr.net/gh/{repo}@{ref}/{path}"
52
+ JSDELIVR_DATA = "https://data.jsdelivr.com/v1/packages/gh/{repo}@{ref}"
53
+
54
+ # The hub directory shipped inside this package. Defaults to <pkg>/hub_data/
55
+ # (this file is at <pkg>/hub.py). Named ``hub_data`` rather than ``hub`` so it
56
+ # does not shadow this module. Overridable via set_bundled_hub_dir() for tests.
57
+ _bundled_hub_dir: Path = Path(__file__).parent / "hub_data"
58
+
59
+
60
+ class HubError(RuntimeError):
61
+ """Hub fetch failure with a human-readable remediation hint.
62
+
63
+ The CLI prints `message` + `hint` to stderr instead of dumping a stack
64
+ trace. `status` is the HTTP status (0 for network errors).
65
+
66
+ Inherits from RuntimeError so existing `except RuntimeError` callers
67
+ continue to work.
68
+ """
69
+
70
+ def __init__(self, message: str, *, hint: str = "", status: int = 0) -> None:
71
+ super().__init__(message)
72
+ self.hint = hint
73
+ self.status = status
74
+
75
+
76
+ def set_bundled_hub_dir(p: Path) -> Path:
77
+ """Override the bundled hub directory (used by tests). Returns the prior."""
78
+ global _bundled_hub_dir
79
+ prev = _bundled_hub_dir
80
+ _bundled_hub_dir = Path(p)
81
+ return prev
82
+
83
+
84
+ def _bundled_has(name: str) -> bool:
85
+ return (_bundled_hub_dir / name / "profile.yaml").exists()
86
+
87
+
88
+ def _auth_headers() -> Dict[str, str]:
89
+ return {"User-Agent": "agentproc-cli"}
90
+
91
+
92
+ _NETWORK_HINT = (
93
+ "Could not reach the hub CDN (jsDelivr). Try:\n"
94
+ " 1. Re-run the command (often succeeds on retry).\n"
95
+ " 2. If your network requires a proxy, set HTTPS_PROXY.\n"
96
+ " 3. Profiles ship bundled with this CLI, so the common case needs no\n"
97
+ ' network. To use a local checkout instead:\n'
98
+ ' agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"'
99
+ )
100
+
101
+
102
+ def _cache_root() -> Path:
103
+ """Root cache directory: ~/.agentproc/cache/hub/"""
104
+ return Path.home() / ".agentproc" / "cache" / "hub"
105
+
106
+
107
+ def cache_dir(name: str) -> Path:
108
+ """Local cache path for a profile."""
109
+ return _cache_root() / name
110
+
111
+
112
+ def _cache_age_secs(name: str) -> Optional[float]:
113
+ """Seconds since the cache was last written. None if not cached."""
114
+ p = cache_dir(name)
115
+ marker = p / ".cache-meta.json"
116
+ if not marker.exists():
117
+ return None
118
+ try:
119
+ meta = json.loads(marker.read_text(encoding="utf-8"))
120
+ ts = meta.get("fetched_at", 0)
121
+ return max(0, time.time() - ts)
122
+ except Exception:
123
+ return None
124
+
125
+
126
+ def _write_cache_meta(name: str) -> None:
127
+ """Write a small metadata file recording when we fetched."""
128
+ p = cache_dir(name)
129
+ p.mkdir(parents=True, exist_ok=True)
130
+ (p / ".cache-meta.json").write_text(
131
+ json.dumps({"fetched_at": time.time(), "ref": HUB_REF}),
132
+ encoding="utf-8",
133
+ )
134
+
135
+
136
+ def _http_get_json(url: str, timeout: int = 30) -> Any:
137
+ """GET a URL, return parsed JSON. Raises HubError on failure."""
138
+ req = urllib.request.Request(url, headers=_auth_headers())
139
+ try:
140
+ with urllib.request.urlopen(req, timeout=timeout) as r:
141
+ return json.loads(r.read().decode("utf-8"))
142
+ except urllib.error.HTTPError as e:
143
+ body = ""
144
+ try:
145
+ body = e.read().decode("utf-8", errors="replace")
146
+ except Exception:
147
+ pass
148
+ raise HubError(
149
+ f"hub CDN returned HTTP {e.code}",
150
+ status=e.code,
151
+ hint=body[:200] or _NETWORK_HINT,
152
+ ) from e
153
+ except urllib.error.URLError as e:
154
+ raise HubError("could not reach the hub CDN", status=0, hint=_NETWORK_HINT) from e
155
+
156
+
157
+ def _http_get_text(url: str, timeout: int = 30) -> str:
158
+ req = urllib.request.Request(url, headers=_auth_headers())
159
+ try:
160
+ with urllib.request.urlopen(req, timeout=timeout) as r:
161
+ return r.read().decode("utf-8")
162
+ except urllib.error.HTTPError as e:
163
+ body = ""
164
+ try:
165
+ body = e.read().decode("utf-8", errors="replace")
166
+ except Exception:
167
+ pass
168
+ if e.code == 404:
169
+ raise HubError(
170
+ f"fetch failed (HTTP 404) for {url}",
171
+ status=404,
172
+ hint="Profile files should exist in the hub repo. Try `agentproc hub list`.",
173
+ ) from e
174
+ raise HubError(
175
+ f"fetch failed (HTTP {e.code}) for {url}",
176
+ status=e.code,
177
+ hint=body[:200] or _NETWORK_HINT,
178
+ ) from e
179
+ except urllib.error.URLError as e:
180
+ raise HubError("could not reach the hub CDN", status=0, hint=_NETWORK_HINT) from e
181
+
182
+
183
+ def _http_get_text_optional(url: str, timeout: int = 30) -> Optional[str]:
184
+ """Like _http_get_text, but returns None on 404 instead of raising.
185
+
186
+ Used for probing optional profile files (e.g. bridge.sh only exists for
187
+ echo-agent) and for detecting "profile does not exist" without a separate
188
+ API call. Delegates to _http_get_text so callers that patch
189
+ _http_get_text in tests automatically cover this path.
190
+ """
191
+ try:
192
+ return _http_get_text(url, timeout=timeout)
193
+ except HubError as e:
194
+ if e.status == 404:
195
+ return None
196
+ raise
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Repo tree (jsDelivr data API) — cached in-memory and on disk (24h TTL)
201
+ # ---------------------------------------------------------------------------
202
+
203
+ _tree_cache: Optional[List[Dict[str, str]]] = None
204
+
205
+
206
+ def _tree_cache_path() -> Path:
207
+ return _cache_root() / "tree.json"
208
+
209
+
210
+ def _clear_tree_cache() -> None:
211
+ """Drop the in-memory tree cache and delete the disk cache file."""
212
+ global _tree_cache
213
+ _tree_cache = None
214
+ p = _tree_cache_path()
215
+ if p.exists():
216
+ try:
217
+ p.unlink()
218
+ except OSError:
219
+ pass
220
+
221
+
222
+ def _flatten_jsdelivr_tree(files: list, prefix: str = "") -> List[Dict[str, str]]:
223
+ """Flatten jsDelivr's nested {files:[{type:'directory', files:[...]}]} tree
224
+ into the flat [{path, type:'blob'|'tree'}] shape the rest of this module
225
+ expects (same shape GitHub's Trees API returned).
226
+ """
227
+ out: List[Dict[str, str]] = []
228
+ for e in files:
229
+ if not isinstance(e, dict):
230
+ continue
231
+ p = prefix + str(e.get("name", ""))
232
+ if e.get("type") == "directory":
233
+ out.append({"path": p, "type": "tree"})
234
+ if isinstance(e.get("files"), list):
235
+ out.extend(_flatten_jsdelivr_tree(e["files"], p + "/"))
236
+ else:
237
+ out.append({"path": p, "type": "blob"})
238
+ return out
239
+
240
+
241
+ def _get_tree() -> List[Dict[str, str]]:
242
+ """Return the full repo tree as a list of {path, type('blob'|'tree')}.
243
+
244
+ Serves from in-memory cache → disk cache (24h TTL) → jsDelivr data API,
245
+ writing each layer as it misses. The API is not rate-limited like
246
+ GitHub's Trees API.
247
+ """
248
+ global _tree_cache
249
+ if _tree_cache is not None:
250
+ return _tree_cache
251
+
252
+ tp = _tree_cache_path()
253
+ if tp.exists():
254
+ try:
255
+ meta = json.loads(tp.read_text(encoding="utf-8"))
256
+ age = max(0.0, time.time() - float(meta.get("fetched_at", 0)))
257
+ if age < HUB_CACHE_TTL_SECS and isinstance(meta.get("tree"), list):
258
+ _tree_cache = [
259
+ {"path": str(e.get("path", "")), "type": str(e.get("type", ""))}
260
+ for e in meta["tree"] if isinstance(e, dict)
261
+ ]
262
+ return _tree_cache
263
+ except (ValueError, OSError):
264
+ pass # corrupt cache file — refetch
265
+
266
+ url = JSDELIVR_DATA.format(repo=HUB_REPO, ref=HUB_REF)
267
+ data = _http_get_json(url)
268
+ if not isinstance(data, dict) or not isinstance(data.get("files"), list):
269
+ raise RuntimeError(f"unexpected jsDelivr data API response: {type(data).__name__}")
270
+ _tree_cache = _flatten_jsdelivr_tree(data["files"])
271
+
272
+ try:
273
+ _cache_root().mkdir(parents=True, exist_ok=True)
274
+ tp.write_text(
275
+ json.dumps(
276
+ {"fetched_at": time.time(), "ref": HUB_REF, "tree": _tree_cache},
277
+ ),
278
+ encoding="utf-8",
279
+ )
280
+ except OSError:
281
+ pass # disk cache is best-effort
282
+
283
+ return _tree_cache
284
+
285
+
286
+ def _list_remote_files(subpath: str) -> List[Dict[str, str]]:
287
+ """List top-level entries in a hub subpath (e.g. 'hub' → all profile dirs)."""
288
+ if not subpath.endswith("/"):
289
+ subpath = subpath + "/"
290
+ tree = _get_tree()
291
+ out: List[Dict[str, str]] = []
292
+ seen = set()
293
+ for entry in tree:
294
+ p = entry["path"]
295
+ if not p.startswith(subpath):
296
+ continue
297
+ name = p[len(subpath):].split("/")[0]
298
+ if not name or name in seen:
299
+ continue
300
+ seen.add(name)
301
+ is_dir = any(t["path"] == subpath + name and t["type"] == "tree" for t in tree)
302
+ out.append({
303
+ "name": name,
304
+ "path": p,
305
+ "type": "dir" if is_dir else "file",
306
+ "download_url": "",
307
+ })
308
+ return out
309
+
310
+
311
+ def _list_profile_names() -> List[str]:
312
+ """List top-level profile names (directories directly under hub/).
313
+
314
+ Uses the bundled copy if present (no network), else the disk-cached
315
+ remote tree. `_`-prefixed utility dirs (e.g. `_shared`) are excluded.
316
+ """
317
+ if _bundled_hub_dir.exists():
318
+ names = []
319
+ for entry in _bundled_hub_dir.iterdir():
320
+ if entry.is_dir() and not entry.name.startswith("_") \
321
+ and (entry / "profile.yaml").exists():
322
+ names.append(entry.name)
323
+ return sorted(names)
324
+ tree = _get_tree()
325
+ names: List[str] = []
326
+ seen = set()
327
+ for entry in tree:
328
+ p = entry["path"]
329
+ if not p.startswith("hub/"):
330
+ continue
331
+ seg = p[len("hub/"):].split("/")[0]
332
+ if seg and not seg.startswith("_") and seg not in seen:
333
+ seen.add(seg)
334
+ names.append(seg)
335
+ return sorted(names)
336
+
337
+
338
+ def _suggest_close_name(input_name: str, candidates: List[str]) -> str:
339
+ """Lightweight "did you mean" hint.
340
+
341
+ Two paths to a match:
342
+ 1. Prefix match — `claude` matches `claude-code`, `echo` matches
343
+ `echo-agent`. Common typo pattern (user forgot a suffix).
344
+ Only accepts an unambiguous prefix.
345
+ 2. Edit distance with length-scaled threshold (≤6 → 1 edit,
346
+ 7-12 → 2, >12 → 3). Catches transpositions and small typos.
347
+ """
348
+ if not input_name or not candidates:
349
+ return ""
350
+ n = input_name.lower()
351
+ prefix_matches = [c for c in candidates if c.lower().startswith(n)]
352
+ if len(prefix_matches) == 1:
353
+ return prefix_matches[0]
354
+ threshold = 1 if len(input_name) <= 6 else (2 if len(input_name) <= 12 else 3)
355
+ best = ""
356
+ best_dist = float("inf")
357
+ for c in candidates:
358
+ d = _edit_distance(n, c.lower())
359
+ if d < best_dist:
360
+ best_dist = d
361
+ best = c
362
+ if best and best_dist <= threshold:
363
+ return best
364
+ return ""
365
+
366
+
367
+ def _edit_distance(a: str, b: str) -> int:
368
+ m, n = len(a), len(b)
369
+ if m == 0:
370
+ return n
371
+ if n == 0:
372
+ return m
373
+ prev = list(range(n + 1))
374
+ curr = [0] * (n + 1)
375
+ for i in range(1, m + 1):
376
+ curr[0] = i
377
+ for j in range(1, n + 1):
378
+ cost = 0 if a[i - 1] == b[j - 1] else 1
379
+ curr[j] = min(
380
+ prev[j] + 1, # deletion
381
+ curr[j - 1] + 1, # insertion
382
+ prev[j - 1] + cost # substitution
383
+ )
384
+ prev, curr = curr, prev
385
+ return prev[n]
386
+
387
+
388
+ # Every hub profile is this fixed set of files (see hub/README.md):
389
+ # profile.yaml (required) + bridge.py + bridge.js + README.md,
390
+ # with echo-agent additionally shipping bridge.sh. `_shared/` ships
391
+ # stream_utils.{py,js} + README.md. If a future profile adds a new file
392
+ # type, extend these tuples.
393
+ _PROFILE_FILE_CANDIDATES = (
394
+ "profile.yaml",
395
+ "bridge.py",
396
+ "bridge.js",
397
+ "bridge.sh",
398
+ "README.md",
399
+ )
400
+ _SHARED_FILE_CANDIDATES = ("stream_utils.py", "stream_utils.js", "README.md")
401
+
402
+ # Exclude Python bytecode / editor cruft when copying bundled dirs to cache.
403
+ _COPY_IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc")
404
+
405
+
406
+ def _clear_dir(d: Path) -> None:
407
+ if d.exists():
408
+ shutil.rmtree(d)
409
+ d.mkdir(parents=True, exist_ok=True)
410
+
411
+
412
+ def _copy_bundled(subname: str, dest: Path) -> bool:
413
+ src = _bundled_hub_dir / subname
414
+ if not src.exists():
415
+ return False
416
+ # copytree creates dest itself; it errors if dest already exists, so wipe
417
+ # first without recreating.
418
+ if dest.exists():
419
+ shutil.rmtree(dest)
420
+ shutil.copytree(src, dest, ignore=_COPY_IGNORE)
421
+ return True
422
+
423
+
424
+ def _ensure_shared_cached(*, refresh: bool, on_log) -> None:
425
+ """Ensure `_shared/` is in the cache root, from the bundle or jsDelivr.
426
+
427
+ Bridges do `from _shared.stream_utils import ...` with the cache root on
428
+ sys.path, so this must be populated whenever a profile is fetched.
429
+ Skipped if a fresh _shared cache already exists.
430
+ """
431
+ age = _cache_age_secs("_shared")
432
+ sdir = cache_dir("_shared")
433
+ if not refresh and age is not None and age < HUB_CACHE_TTL_SECS \
434
+ and (sdir / "stream_utils.py").exists():
435
+ return
436
+ if _bundled_hub_dir.exists():
437
+ if _copy_bundled("_shared", sdir):
438
+ _write_cache_meta("_shared")
439
+ return
440
+ # Remote: fetch the candidate file set via jsDelivr raw URLs.
441
+ _clear_dir(sdir)
442
+ for fname in _SHARED_FILE_CANDIDATES:
443
+ url = JSDELIVR_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=f"hub/_shared/{fname}")
444
+ text = _http_get_text_optional(url)
445
+ if text is None:
446
+ continue
447
+ (sdir / fname).write_text(text, encoding="utf-8")
448
+ if on_log:
449
+ on_log(f" - _shared/{fname}")
450
+ _write_cache_meta("_shared")
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Public API
455
+ # ---------------------------------------------------------------------------
456
+
457
+ def fetch_profile(
458
+ name: str,
459
+ refresh: bool = False,
460
+ on_log: Optional[Callable[[str], None]] = None,
461
+ ) -> Path:
462
+ """Fetch a profile directory to local cache. Returns the cache path.
463
+
464
+ Resolution: fresh cache → bundled copy (default, zero network) → jsDelivr
465
+ CDN (for refresh or a profile not in the bundle). ``_shared/`` is
466
+ populated alongside so the bridge scripts can import it.
467
+
468
+ If a fresh cache exists (younger than HUB_CACHE_TTL_SECS) and refresh
469
+ is False, returns immediately without network access.
470
+ """
471
+ if refresh:
472
+ _clear_tree_cache()
473
+
474
+ age = _cache_age_secs(name)
475
+ cached = cache_dir(name)
476
+ profile_yaml = cached / "profile.yaml"
477
+
478
+ if not refresh and age is not None and age < HUB_CACHE_TTL_SECS and profile_yaml.exists():
479
+ if on_log:
480
+ on_log(f"using cached profile: {cached} (age {int(age)}s)")
481
+ return cached
482
+
483
+ # 1) Bundled fast path — zero network, the default for most users.
484
+ if not refresh and _bundled_has(name):
485
+ if on_log:
486
+ on_log(f"using bundled profile: {name}")
487
+ _copy_bundled(name, cached)
488
+ _write_cache_meta(name)
489
+ _ensure_shared_cached(refresh=refresh, on_log=on_log)
490
+ return cached
491
+
492
+ if on_log:
493
+ if refresh:
494
+ on_log(f"refreshing profile '{name}' from jsDelivr CDN...")
495
+ else:
496
+ on_log(f"fetching profile '{name}' from jsDelivr CDN...")
497
+
498
+ # 2) Remote via jsDelivr. Probe profile.yaml first.
499
+ probe_url = JSDELIVR_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/profile.yaml")
500
+ probe = _http_get_text_optional(probe_url)
501
+ if probe is None:
502
+ # profile.yaml 404 → wrong name. Produce a "did you mean" hint from
503
+ # the bundled listing (no network) or the disk-cached remote tree.
504
+ known: List[str] = []
505
+ try:
506
+ known = _list_profile_names()
507
+ except Exception:
508
+ pass
509
+ suggestion = _suggest_close_name(name, known)
510
+ lines = []
511
+ if suggestion:
512
+ lines.append(f"Did you mean `{suggestion}`?")
513
+ lines.append("")
514
+ lines.append("Available profiles:")
515
+ for k in known:
516
+ lines.append(f" - {k}")
517
+ raise HubError(
518
+ f"profile '{name}' not found in hub",
519
+ status=404,
520
+ hint="\n".join(lines),
521
+ )
522
+
523
+ # Clear cache, then download the candidate file set via raw URLs.
524
+ _clear_dir(cached)
525
+ (cached / "profile.yaml").write_text(probe, encoding="utf-8")
526
+ if on_log:
527
+ on_log(" - profile.yaml")
528
+
529
+ for fname in _PROFILE_FILE_CANDIDATES:
530
+ if fname == "profile.yaml":
531
+ continue
532
+ url = JSDELIVR_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/{fname}")
533
+ text = _http_get_text_optional(url)
534
+ if text is None:
535
+ continue # optional file not present for this profile
536
+ (cached / fname).write_text(text, encoding="utf-8")
537
+ if on_log:
538
+ on_log(f" - {fname}")
539
+
540
+ _write_cache_meta(name)
541
+ _ensure_shared_cached(refresh=refresh, on_log=on_log)
542
+ return cached
543
+
544
+
545
+ def list_profiles(
546
+ refresh: bool = False,
547
+ on_log: Optional[Callable[[str], None]] = None,
548
+ ) -> List[Dict[str, str]]:
549
+ """List profiles in the official hub.
550
+
551
+ Reads from the bundled copy by default (zero network). With refresh=True
552
+ (or no bundle) it queries jsDelivr's data API and fetches each
553
+ profile.yaml for metadata.
554
+
555
+ Returns list of dicts: {name, description, cli, tested}.
556
+ """
557
+ from .cli import parse_yaml # local import to avoid cycle
558
+
559
+ if not refresh and _bundled_hub_dir.exists():
560
+ profiles: List[Dict[str, str]] = []
561
+ for entry in _bundled_hub_dir.iterdir():
562
+ if not (entry.is_dir() and not entry.name.startswith("_")):
563
+ continue
564
+ yaml_path = entry / "profile.yaml"
565
+ if not yaml_path.exists():
566
+ continue
567
+ try:
568
+ data = parse_yaml(yaml_path.read_text(encoding="utf-8"))
569
+ profiles.append({
570
+ "name": str(data.get("name", entry.name)),
571
+ "description": str(data.get("description", "")),
572
+ "cli": str(data.get("cli", "")),
573
+ "tested": str(data.get("tested", "unverified")),
574
+ })
575
+ except Exception as e:
576
+ if on_log:
577
+ on_log(f"warning: could not read metadata for {entry.name}: {e}")
578
+ profiles.append({
579
+ "name": entry.name,
580
+ "description": "(failed to read metadata)",
581
+ "cli": "",
582
+ "tested": "unverified",
583
+ })
584
+ return profiles
585
+
586
+ entries = _list_remote_files("hub")
587
+ profiles = []
588
+ for entry in entries:
589
+ if entry["type"] != "dir":
590
+ continue
591
+ name = entry["name"]
592
+ if name.startswith("_"):
593
+ continue
594
+ try:
595
+ yaml_url = JSDELIVR_RAW.format(
596
+ repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/profile.yaml"
597
+ )
598
+ yaml_text = _http_get_text(yaml_url)
599
+ data = parse_yaml(yaml_text)
600
+ profiles.append({
601
+ "name": str(data.get("name", name)),
602
+ "description": str(data.get("description", "")),
603
+ "cli": str(data.get("cli", "")),
604
+ "tested": str(data.get("tested", "unverified")),
605
+ })
606
+ except Exception as e:
607
+ if on_log:
608
+ on_log(f"warning: could not read metadata for {name}: {e}")
609
+ profiles.append({
610
+ "name": name,
611
+ "description": "(failed to read metadata)",
612
+ "cli": "",
613
+ "tested": "unverified",
614
+ })
615
+ return profiles
616
+
617
+
618
+ def show_readme(
619
+ name: str,
620
+ refresh: bool = False,
621
+ on_log: Optional[Callable[[str], None]] = None,
622
+ ) -> str:
623
+ """Return the README.md content for a profile (fetches if needed)."""
624
+ cached = fetch_profile(name, refresh=refresh, on_log=on_log)
625
+ readme = cached / "README.md"
626
+ if not readme.exists():
627
+ return f"(no README.md for profile '{name}')"
628
+ return readme.read_text(encoding="utf-8")
629
+
630
+
631
+ def install_profile(
632
+ name: str,
633
+ target_dir: Path,
634
+ refresh: bool = False,
635
+ on_log: Optional[Callable[[str], None]] = None,
636
+ ) -> Path:
637
+ """Copy a profile into target_dir/<name>/, along with `_shared/`.
638
+
639
+ The bridge scripts import from `_shared` via a sibling path, so it must
640
+ be installed alongside for the installed profile to run.
641
+ """
642
+ cached = fetch_profile(name, refresh=refresh, on_log=on_log)
643
+ dest = Path(target_dir) / name
644
+ if dest.exists():
645
+ raise RuntimeError(f"target already exists: {dest}")
646
+ shutil.copytree(cached, dest, ignore=_COPY_IGNORE)
647
+ meta = dest / ".cache-meta.json"
648
+ if meta.exists():
649
+ meta.unlink()
650
+ shared_src = cache_dir("_shared")
651
+ shared_dest = Path(target_dir) / "_shared"
652
+ if shared_src.exists() and not shared_dest.exists():
653
+ shutil.copytree(shared_src, shared_dest, ignore=_COPY_IGNORE)
654
+ if on_log:
655
+ on_log(f"installed to: {dest}")
656
+ return dest