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.
- {agentproc-0.4.1 → agentproc-0.4.2}/PKG-INFO +1 -1
- {agentproc-0.4.1 → agentproc-0.4.2}/pyproject.toml +6 -1
- {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc/hub.py +224 -150
- agentproc-0.4.2/src/agentproc/hub_data/README.md +134 -0
- agentproc-0.4.2/src/agentproc/hub_data/_shared/README.md +52 -0
- agentproc-0.4.2/src/agentproc/hub_data/_shared/stream_utils.js +130 -0
- agentproc-0.4.2/src/agentproc/hub_data/_shared/stream_utils.py +161 -0
- agentproc-0.4.2/src/agentproc/hub_data/agy/README.md +114 -0
- agentproc-0.4.2/src/agentproc/hub_data/agy/bridge.js +87 -0
- agentproc-0.4.2/src/agentproc/hub_data/agy/bridge.py +75 -0
- agentproc-0.4.2/src/agentproc/hub_data/agy/profile.yaml +27 -0
- agentproc-0.4.2/src/agentproc/hub_data/claude-code/README.md +132 -0
- agentproc-0.4.2/src/agentproc/hub_data/claude-code/bridge.js +65 -0
- agentproc-0.4.2/src/agentproc/hub_data/claude-code/bridge.py +76 -0
- agentproc-0.4.2/src/agentproc/hub_data/claude-code/profile.yaml +26 -0
- agentproc-0.4.2/src/agentproc/hub_data/codebuddy/README.md +108 -0
- agentproc-0.4.2/src/agentproc/hub_data/codebuddy/bridge.js +60 -0
- agentproc-0.4.2/src/agentproc/hub_data/codebuddy/bridge.py +70 -0
- agentproc-0.4.2/src/agentproc/hub_data/codebuddy/profile.yaml +23 -0
- agentproc-0.4.2/src/agentproc/hub_data/codex/README.md +118 -0
- agentproc-0.4.2/src/agentproc/hub_data/codex/bridge.js +54 -0
- agentproc-0.4.2/src/agentproc/hub_data/codex/bridge.py +59 -0
- agentproc-0.4.2/src/agentproc/hub_data/codex/profile.yaml +25 -0
- agentproc-0.4.2/src/agentproc/hub_data/cursor/README.md +141 -0
- agentproc-0.4.2/src/agentproc/hub_data/cursor/bridge.js +83 -0
- agentproc-0.4.2/src/agentproc/hub_data/cursor/bridge.py +119 -0
- agentproc-0.4.2/src/agentproc/hub_data/cursor/profile.yaml +43 -0
- agentproc-0.4.2/src/agentproc/hub_data/echo-agent/README.md +50 -0
- agentproc-0.4.2/src/agentproc/hub_data/echo-agent/bridge.js +12 -0
- agentproc-0.4.2/src/agentproc/hub_data/echo-agent/bridge.py +21 -0
- agentproc-0.4.2/src/agentproc/hub_data/echo-agent/bridge.sh +4 -0
- agentproc-0.4.2/src/agentproc/hub_data/echo-agent/profile.yaml +19 -0
- agentproc-0.4.2/src/agentproc/hub_data/gemini-cli/README.md +134 -0
- agentproc-0.4.2/src/agentproc/hub_data/gemini-cli/bridge.js +74 -0
- agentproc-0.4.2/src/agentproc/hub_data/gemini-cli/bridge.py +91 -0
- agentproc-0.4.2/src/agentproc/hub_data/gemini-cli/profile.yaml +28 -0
- agentproc-0.4.2/src/agentproc/hub_data/qwen-code/README.md +93 -0
- agentproc-0.4.2/src/agentproc/hub_data/qwen-code/bridge.js +69 -0
- agentproc-0.4.2/src/agentproc/hub_data/qwen-code/bridge.py +78 -0
- agentproc-0.4.2/src/agentproc/hub_data/qwen-code/profile.yaml +26 -0
- agentproc-0.4.2/src/agentproc/hub_data/recursive/README.md +156 -0
- agentproc-0.4.2/src/agentproc/hub_data/recursive/bridge.js +330 -0
- agentproc-0.4.2/src/agentproc/hub_data/recursive/bridge.py +357 -0
- agentproc-0.4.2/src/agentproc/hub_data/recursive/profile.yaml +71 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc.egg-info/PKG-INFO +1 -1
- agentproc-0.4.2/src/agentproc.egg-info/SOURCES.txt +55 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/tests/test_hub.py +119 -47
- agentproc-0.4.1/src/agentproc.egg-info/SOURCES.txt +0 -14
- {agentproc-0.4.1 → agentproc-0.4.2}/setup.cfg +0 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc/__init__.py +0 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc/cli.py +0 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc/runner.py +0 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc.egg-info/dependency_links.txt +0 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc.egg-info/entry_points.txt +0 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/src/agentproc.egg-info/top_level.txt +0 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/tests/test_agentproc.py +0 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/tests/test_conformance.py +0 -0
- {agentproc-0.4.1 → agentproc-0.4.2}/tests/test_runner.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentproc"
|
|
7
|
-
version = "0.4.
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
58
|
-
"""
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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(
|
|
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
|
-
"
|
|
124
|
-
status=
|
|
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(
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
"
|
|
150
|
-
status=
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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) →
|
|
249
|
-
each layer as it misses. The API is
|
|
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 =
|
|
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("
|
|
272
|
-
raise RuntimeError(f"unexpected
|
|
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
|
-
|
|
325
|
-
|
|
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.
|
|
398
|
-
#
|
|
399
|
-
#
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
494
|
+
on_log(f"refreshing profile '{name}' from jsDelivr CDN...")
|
|
444
495
|
else:
|
|
445
|
-
on_log(f"fetching profile '{name}' from
|
|
496
|
+
on_log(f"fetching profile '{name}' from jsDelivr CDN...")
|
|
446
497
|
|
|
447
|
-
#
|
|
448
|
-
|
|
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 →
|
|
454
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
637
|
+
"""Copy a profile into target_dir/<name>/, along with `_shared/`.
|
|
564
638
|
|
|
565
|
-
|
|
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
|