agentproc 0.4.1__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 (58) hide show
  1. {agentproc-0.4.1 → agentproc-0.4.2}/PKG-INFO +1 -1
  2. {agentproc-0.4.1 → agentproc-0.4.2}/pyproject.toml +6 -1
  3. {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc/hub.py +224 -150
  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.1 → 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.1 → agentproc-0.4.2}/tests/test_hub.py +119 -47
  48. agentproc-0.4.1/src/agentproc.egg-info/SOURCES.txt +0 -14
  49. {agentproc-0.4.1 → agentproc-0.4.2}/setup.cfg +0 -0
  50. {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc/__init__.py +0 -0
  51. {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc/cli.py +0 -0
  52. {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc/runner.py +0 -0
  53. {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc.egg-info/dependency_links.txt +0 -0
  54. {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc.egg-info/entry_points.txt +0 -0
  55. {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc.egg-info/top_level.txt +0 -0
  56. {agentproc-0.4.1 → agentproc-0.4.2}/tests/test_agentproc.py +0 -0
  57. {agentproc-0.4.1 → agentproc-0.4.2}/tests/test_conformance.py +0 -0
  58. {agentproc-0.4.1 → 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.1
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.1"
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/*"]
@@ -1,8 +1,22 @@
1
1
  """Hub client — fetch and manage profile directories from the official Hub.
2
2
 
3
3
  The Hub lives at https://github.com/jeffkit/agentproc/tree/main/hub/
4
- Profiles are cached locally at ~/.agentproc/cache/hub/<name>/ with a
5
- 24-hour TTL. Pass refresh=True to force re-fetch.
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).
6
20
 
7
21
  Public API:
8
22
  HUB_REPO — the github repo id ("jeffkit/agentproc")
@@ -22,7 +36,6 @@ from __future__ import annotations
22
36
  import json
23
37
  import os
24
38
  import shutil
25
- import sys
26
39
  import time
27
40
  import urllib.error
28
41
  import urllib.request
@@ -33,9 +46,15 @@ HUB_REPO = "jeffkit/agentproc"
33
46
  HUB_REF = "main"
34
47
  HUB_CACHE_TTL_SECS = 24 * 60 * 60 # 24 hours
35
48
 
36
- GITHUB_API = "https://api.github.com/repos/{repo}/contents/{path}?ref={ref}"
37
- GITHUB_TREES = "https://api.github.com/repos/{repo}/git/trees/{ref}?recursive=1"
38
- GITHUB_RAW = "https://raw.githubusercontent.com/{repo}/{ref}/{path}"
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"
39
58
 
40
59
 
41
60
  class HubError(RuntimeError):
@@ -54,20 +73,30 @@ class HubError(RuntimeError):
54
73
  self.status = status
55
74
 
56
75
 
57
- def _auth_headers(*, json: bool = False) -> Dict[str, str]:
58
- """Build HTTP headers, adding Authorization if a token is in env.
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()
59
86
 
60
- GITHUB_TOKEN (the env var GitHub Actions injects) or GH_TOKEN (what
61
- the `gh` CLI sets) raises GitHub's anonymous rate limit from ~60/hr
62
- to ~5000/hr.
63
- """
64
- token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or ""
65
- h: Dict[str, str] = {"User-Agent": "agentproc-cli"}
66
- if json:
67
- h["Accept"] = "application/vnd.github+json"
68
- if token:
69
- h["Authorization"] = f"Bearer {token}"
70
- return h
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
+ )
71
100
 
72
101
 
73
102
  def _cache_root() -> Path:
@@ -106,7 +135,7 @@ def _write_cache_meta(name: str) -> None:
106
135
 
107
136
  def _http_get_json(url: str, timeout: int = 30) -> Any:
108
137
  """GET a URL, return parsed JSON. Raises HubError on failure."""
109
- req = urllib.request.Request(url, headers=_auth_headers(json=True))
138
+ req = urllib.request.Request(url, headers=_auth_headers())
110
139
  try:
111
140
  with urllib.request.urlopen(req, timeout=timeout) as r:
112
141
  return json.loads(r.read().decode("utf-8"))
@@ -116,24 +145,17 @@ def _http_get_json(url: str, timeout: int = 30) -> Any:
116
145
  body = e.read().decode("utf-8", errors="replace")
117
146
  except Exception:
118
147
  pass
119
- raise _http_error_to_hub(e.code, body[:200], url, is_json=True) from e
120
- except urllib.error.URLError as e:
121
- # Network/DNS/connection-level errors (no HTTP status).
122
148
  raise HubError(
123
- "could not reach GitHub while fetching hub profile",
124
- status=0,
125
- hint=(
126
- "This is usually a transient network issue. Try:\n"
127
- " 1. Re-run the command (often succeeds on retry).\n"
128
- " 2. If your network requires a proxy, set HTTPS_PROXY.\n"
129
- " 3. To avoid the network entirely, run against a local checkout:\n"
130
- " agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\""
131
- ),
149
+ f"hub CDN returned HTTP {e.code}",
150
+ status=e.code,
151
+ hint=body[:200] or _NETWORK_HINT,
132
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
133
155
 
134
156
 
135
157
  def _http_get_text(url: str, timeout: int = 30) -> str:
136
- req = urllib.request.Request(url, headers=_auth_headers(json=False))
158
+ req = urllib.request.Request(url, headers=_auth_headers())
137
159
  try:
138
160
  with urllib.request.urlopen(req, timeout=timeout) as r:
139
161
  return r.read().decode("utf-8")
@@ -143,28 +165,28 @@ def _http_get_text(url: str, timeout: int = 30) -> str:
143
165
  body = e.read().decode("utf-8", errors="replace")
144
166
  except Exception:
145
167
  pass
146
- raise _http_error_to_hub(e.code, body[:200], url, is_json=False) from e
147
- except urllib.error.URLError as e:
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
148
174
  raise HubError(
149
- "could not reach GitHub while fetching hub profile",
150
- status=0,
151
- hint=(
152
- "This is usually a transient network issue. Try:\n"
153
- " 1. Re-run the command (often succeeds on retry).\n"
154
- " 2. If your network requires a proxy, set HTTPS_PROXY.\n"
155
- " 3. To avoid the network entirely, run against a local checkout:\n"
156
- " agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\""
157
- ),
175
+ f"fetch failed (HTTP {e.code}) for {url}",
176
+ status=e.code,
177
+ hint=body[:200] or _NETWORK_HINT,
158
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
159
181
 
160
182
 
161
183
  def _http_get_text_optional(url: str, timeout: int = 30) -> Optional[str]:
162
184
  """Like _http_get_text, but returns None on 404 instead of raising.
163
185
 
164
186
  Used for probing optional profile files (e.g. bridge.sh only exists for
165
- echo-agent) and for detecting "profile does not exist" without burning
166
- a rate-limited Trees API call. Delegates to _http_get_text so callers
167
- that patch _http_get_text in tests automatically cover this path.
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.
168
190
  """
169
191
  try:
170
192
  return _http_get_text(url, timeout=timeout)
@@ -174,54 +196,9 @@ def _http_get_text_optional(url: str, timeout: int = 30) -> Optional[str]:
174
196
  raise
175
197
 
176
198
 
177
- def _http_error_to_hub(status: int, body: str, url: str, *, is_json: bool) -> HubError:
178
- """Translate a GitHub HTTP error into a HubError with a remediation hint."""
179
- authed = bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
180
- if status in (403, 429):
181
- if authed:
182
- hint = (
183
- "Your GITHUB_TOKEN is set but still rate-limited. Wait a few minutes and retry,\n"
184
- "or run against a local checkout instead:\n"
185
- " agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\"\n"
186
- "\n"
187
- "Not sure the profile name is right? Check with: agentproc hub list"
188
- )
189
- else:
190
- hint = (
191
- "GitHub limits anonymous hub fetches to ~60/hour. To raise this to 5,000/hour:\n"
192
- " export GITHUB_TOKEN=$(gh auth token) # if you have the GitHub CLI\n"
193
- " # or set GITHUB_TOKEN to any personal access token\n"
194
- "\n"
195
- "To skip the network entirely, run against a local checkout:\n"
196
- " git clone https://github.com/jeffkit/agentproc && cd agentproc\n"
197
- " agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\"\n"
198
- "\n"
199
- "Not sure the profile name is right? Check with: agentproc hub list"
200
- )
201
- return HubError(f"GitHub rate-limited the hub fetch (HTTP {status})", status=status, hint=hint)
202
- if status == 404:
203
- return HubError(
204
- "profile not found on GitHub (HTTP 404)",
205
- status=404,
206
- hint="Check the profile name with `agentproc hub list`. (Typos are case-sensitive.)",
207
- )
208
- return HubError(
209
- f"GitHub returned HTTP {status} for hub fetch",
210
- status=status,
211
- hint=body or "No additional detail from GitHub.",
212
- )
213
-
214
-
215
199
  # ---------------------------------------------------------------------------
216
- # Repo tree — cached in-memory and on disk (24h TTL)
200
+ # Repo tree (jsDelivr data API) — cached in-memory and on disk (24h TTL)
217
201
  # ---------------------------------------------------------------------------
218
- #
219
- # The GitHub Trees API is the one call that rate-limits anonymous users to
220
- # ~60/hr. Every CLI invocation is a fresh process, so an in-memory cache
221
- # alone doesn't help across calls. The disk cache (~/.agentproc/cache/hub/
222
- # tree.json) is what gives rate-limit relief: a normal user makes at most
223
- # ~1 Trees API call per day, regardless of how many `hub list` / `hub run`
224
- # invocations they run.
225
202
 
226
203
  _tree_cache: Optional[List[Dict[str, str]]] = None
227
204
 
@@ -242,11 +219,31 @@ def _clear_tree_cache() -> None:
242
219
  pass
243
220
 
244
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
+
245
241
  def _get_tree() -> List[Dict[str, str]]:
246
242
  """Return the full repo tree as a list of {path, type('blob'|'tree')}.
247
243
 
248
- Serves from in-memory cache → disk cache (24h TTL) → GitHub API, writing
249
- each layer as it misses. The API is the only rate-limited step.
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.
250
247
  """
251
248
  global _tree_cache
252
249
  if _tree_cache is not None:
@@ -266,14 +263,11 @@ def _get_tree() -> List[Dict[str, str]]:
266
263
  except (ValueError, OSError):
267
264
  pass # corrupt cache file — refetch
268
265
 
269
- url = GITHUB_TREES.format(repo=HUB_REPO, ref=HUB_REF)
266
+ url = JSDELIVR_DATA.format(repo=HUB_REPO, ref=HUB_REF)
270
267
  data = _http_get_json(url)
271
- if not isinstance(data, dict) or not isinstance(data.get("tree"), list):
272
- raise RuntimeError(f"unexpected tree API response: {type(data).__name__}")
273
- _tree_cache = [
274
- {"path": str(e.get("path", "")), "type": str(e.get("type", ""))}
275
- for e in data["tree"] if isinstance(e, dict)
276
- ]
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"])
277
271
 
278
272
  try:
279
273
  _cache_root().mkdir(parents=True, exist_ok=True)
@@ -290,11 +284,7 @@ def _get_tree() -> List[Dict[str, str]]:
290
284
 
291
285
 
292
286
  def _list_remote_files(subpath: str) -> List[Dict[str, str]]:
293
- """List top-level entries in a hub subpath (e.g. 'hub' → all profile dirs).
294
-
295
- Uses _get_tree(), which is cached in-memory and on disk (24h TTL) so
296
- repeated calls don't re-hit the rate-limited Trees API.
297
- """
287
+ """List top-level entries in a hub subpath (e.g. 'hub' → all profile dirs)."""
298
288
  if not subpath.endswith("/"):
299
289
  subpath = subpath + "/"
300
290
  tree = _get_tree()
@@ -321,9 +311,16 @@ def _list_remote_files(subpath: str) -> List[Dict[str, str]]:
321
311
  def _list_profile_names() -> List[str]:
322
312
  """List top-level profile names (directories directly under hub/).
323
313
 
324
- Cheap: uses the disk-cached tree, so this is at most ~1 Trees API call
325
- per day regardless of how many times it's called.
314
+ Uses the bundled copy if present (no network), else the disk-cached
315
+ remote tree. `_`-prefixed utility dirs (e.g. `_shared`) are excluded.
326
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)
327
324
  tree = _get_tree()
328
325
  names: List[str] = []
329
326
  seen = set()
@@ -332,8 +329,6 @@ def _list_profile_names() -> List[str]:
332
329
  if not p.startswith("hub/"):
333
330
  continue
334
331
  seg = p[len("hub/"):].split("/")[0]
335
- # Directories prefixed with `_` (e.g. `_shared`) hold bridge
336
- # utilities, not profiles — exclude from listings and suggestions.
337
332
  if seg and not seg.startswith("_") and seg not in seen:
338
333
  seen.add(seg)
339
334
  names.append(seg)
@@ -353,11 +348,9 @@ def _suggest_close_name(input_name: str, candidates: List[str]) -> str:
353
348
  if not input_name or not candidates:
354
349
  return ""
355
350
  n = input_name.lower()
356
- # Path 1: unique prefix match.
357
351
  prefix_matches = [c for c in candidates if c.lower().startswith(n)]
358
352
  if len(prefix_matches) == 1:
359
353
  return prefix_matches[0]
360
- # Path 2: edit distance.
361
354
  threshold = 1 if len(input_name) <= 6 else (2 if len(input_name) <= 12 else 3)
362
355
  best = ""
363
356
  best_dist = float("inf")
@@ -394,10 +387,9 @@ def _edit_distance(a: str, b: str) -> int:
394
387
 
395
388
  # Every hub profile is this fixed set of files (see hub/README.md):
396
389
  # profile.yaml (required) + bridge.py + bridge.js + README.md,
397
- # with echo-agent additionally shipping bridge.sh. We fetch them directly
398
- # via raw.githubusercontent.com (CDN, not rate-limited) so `hub run` never
399
- # calls the GitHub Trees API in the happy path. If a future profile adds a
400
- # new file type, extend this list.
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.
401
393
  _PROFILE_FILE_CANDIDATES = (
402
394
  "profile.yaml",
403
395
  "bridge.py",
@@ -405,6 +397,57 @@ _PROFILE_FILE_CANDIDATES = (
405
397
  "bridge.sh",
406
398
  "README.md",
407
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")
408
451
 
409
452
 
410
453
  # ---------------------------------------------------------------------------
@@ -418,10 +461,9 @@ def fetch_profile(
418
461
  ) -> Path:
419
462
  """Fetch a profile directory to local cache. Returns the cache path.
420
463
 
421
- Fetches files directly via raw.githubusercontent.com (CDN, not
422
- rate-limited) no GitHub Trees API call in the happy path. Only an
423
- unknown profile name (profile.yaml 404) falls back to the disk-cached
424
- tree to produce a "did you mean" suggestion.
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.
425
467
 
426
468
  If a fresh cache exists (younger than HUB_CACHE_TTL_SECS) and refresh
427
469
  is False, returns immediately without network access.
@@ -438,21 +480,27 @@ def fetch_profile(
438
480
  on_log(f"using cached profile: {cached} (age {int(age)}s)")
439
481
  return cached
440
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
+
441
492
  if on_log:
442
493
  if refresh:
443
- on_log(f"refreshing profile '{name}' from {HUB_REPO}:{HUB_REF}...")
494
+ on_log(f"refreshing profile '{name}' from jsDelivr CDN...")
444
495
  else:
445
- on_log(f"fetching profile '{name}' from {HUB_REPO}:{HUB_REF}...")
496
+ on_log(f"fetching profile '{name}' from jsDelivr CDN...")
446
497
 
447
- # Probe profile.yaml via raw URL. raw.githubusercontent.com is CDN-backed
448
- # and not subject to the 60/hr anonymous API limit, so this does not burn
449
- # rate-limit budget.
450
- probe_url = GITHUB_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/profile.yaml")
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")
451
500
  probe = _http_get_text_optional(probe_url)
452
501
  if probe is None:
453
- # profile.yaml 404 → the name is wrong. Fall back to the (disk-cached)
454
- # tree to produce a "did you mean" suggestion. This is the only path
455
- # that may call the Trees API for `hub run`, and it's cached for 24h.
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.
456
504
  known: List[str] = []
457
505
  try:
458
506
  known = _list_profile_names()
@@ -473,11 +521,7 @@ def fetch_profile(
473
521
  )
474
522
 
475
523
  # Clear cache, then download the candidate file set via raw URLs.
476
- if cached.exists():
477
- shutil.rmtree(cached)
478
- cached.mkdir(parents=True, exist_ok=True)
479
-
480
- # profile.yaml already fetched via the probe.
524
+ _clear_dir(cached)
481
525
  (cached / "profile.yaml").write_text(probe, encoding="utf-8")
482
526
  if on_log:
483
527
  on_log(" - profile.yaml")
@@ -485,7 +529,7 @@ def fetch_profile(
485
529
  for fname in _PROFILE_FILE_CANDIDATES:
486
530
  if fname == "profile.yaml":
487
531
  continue
488
- url = GITHUB_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/{fname}")
532
+ url = JSDELIVR_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/{fname}")
489
533
  text = _http_get_text_optional(url)
490
534
  if text is None:
491
535
  continue # optional file not present for this profile
@@ -494,6 +538,7 @@ def fetch_profile(
494
538
  on_log(f" - {fname}")
495
539
 
496
540
  _write_cache_meta(name)
541
+ _ensure_shared_cached(refresh=refresh, on_log=on_log)
497
542
  return cached
498
543
 
499
544
 
@@ -503,25 +548,54 @@ def list_profiles(
503
548
  ) -> List[Dict[str, str]]:
504
549
  """List profiles in the official hub.
505
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
+
506
555
  Returns list of dicts: {name, description, cli, tested}.
507
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
+
508
586
  entries = _list_remote_files("hub")
509
- profiles: List[Dict[str, str]] = []
587
+ profiles = []
510
588
  for entry in entries:
511
589
  if entry["type"] != "dir":
512
590
  continue
513
591
  name = entry["name"]
514
- # Skip utility directories like `_shared/` — they hold shared bridge
515
- # helpers, not a runnable profile (no profile.yaml).
516
592
  if name.startswith("_"):
517
593
  continue
518
- # Read profile.yaml from raw URL to get metadata.
519
594
  try:
520
- yaml_url = GITHUB_RAW.format(
595
+ yaml_url = JSDELIVR_RAW.format(
521
596
  repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/profile.yaml"
522
597
  )
523
598
  yaml_text = _http_get_text(yaml_url)
524
- from .cli import parse_yaml # local import to avoid cycle
525
599
  data = parse_yaml(yaml_text)
526
600
  profiles.append({
527
601
  "name": str(data.get("name", name)),
@@ -560,23 +634,23 @@ def install_profile(
560
634
  refresh: bool = False,
561
635
  on_log: Optional[Callable[[str], None]] = None,
562
636
  ) -> Path:
563
- """Copy a cached profile into target_dir/<name>/.
637
+ """Copy a profile into target_dir/<name>/, along with `_shared/`.
564
638
 
565
- Useful when the user wants to own and edit the profile locally.
639
+ The bridge scripts import from `_shared` via a sibling path, so it must
640
+ be installed alongside for the installed profile to run.
566
641
  """
567
642
  cached = fetch_profile(name, refresh=refresh, on_log=on_log)
568
643
  dest = Path(target_dir) / name
569
644
  if dest.exists():
570
645
  raise RuntimeError(f"target already exists: {dest}")
571
- shutil.copytree(cached, dest)
572
- # Don't copy our cache meta file.
646
+ shutil.copytree(cached, dest, ignore=_COPY_IGNORE)
573
647
  meta = dest / ".cache-meta.json"
574
648
  if meta.exists():
575
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)
576
654
  if on_log:
577
655
  on_log(f"installed to: {dest}")
578
656
  return dest
579
-
580
-
581
- # Re-export for type checker
582
- from typing import Any # noqa: E402