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.
- {agentproc-0.4.0 → agentproc-0.4.2}/PKG-INFO +1 -1
- {agentproc-0.4.0 → agentproc-0.4.2}/pyproject.toml +6 -1
- agentproc-0.4.2/src/agentproc/hub.py +656 -0
- 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.0 → 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.0 → agentproc-0.4.2}/tests/test_hub.py +169 -37
- agentproc-0.4.0/src/agentproc/hub.py +0 -505
- agentproc-0.4.0/src/agentproc.egg-info/SOURCES.txt +0 -14
- {agentproc-0.4.0 → agentproc-0.4.2}/setup.cfg +0 -0
- {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc/__init__.py +0 -0
- {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc/cli.py +0 -0
- {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc/runner.py +0 -0
- {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc.egg-info/dependency_links.txt +0 -0
- {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc.egg-info/entry_points.txt +0 -0
- {agentproc-0.4.0 → agentproc-0.4.2}/src/agentproc.egg-info/top_level.txt +0 -0
- {agentproc-0.4.0 → agentproc-0.4.2}/tests/test_agentproc.py +0 -0
- {agentproc-0.4.0 → agentproc-0.4.2}/tests/test_conformance.py +0 -0
- {agentproc-0.4.0 → 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/*"]
|
|
@@ -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
|