agentproc 0.4.0__tar.gz → 0.4.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentproc
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: AgentProc Protocol SDK + CLI — connect any Agent CLI to a messaging platform
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jeffkit/agentproc
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentproc"
7
- version = "0.4.0"
7
+ version = "0.4.1"
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" }
@@ -158,6 +158,22 @@ def _http_get_text(url: str, timeout: int = 30) -> str:
158
158
  ) from e
159
159
 
160
160
 
161
+ def _http_get_text_optional(url: str, timeout: int = 30) -> Optional[str]:
162
+ """Like _http_get_text, but returns None on 404 instead of raising.
163
+
164
+ 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.
168
+ """
169
+ try:
170
+ return _http_get_text(url, timeout=timeout)
171
+ except HubError as e:
172
+ if e.status == 404:
173
+ return None
174
+ raise
175
+
176
+
161
177
  def _http_error_to_hub(status: int, body: str, url: str, *, is_json: bool) -> HubError:
162
178
  """Translate a GitHub HTTP error into a HubError with a remediation hint."""
163
179
  authed = bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
@@ -196,91 +212,123 @@ def _http_error_to_hub(status: int, body: str, url: str, *, is_json: bool) -> Hu
196
212
  )
197
213
 
198
214
 
199
- def _list_remote_files(subpath: str) -> List[Dict[str, str]]:
200
- """List files in a hub subpath via the git tree API (1 API call for all).
215
+ # ---------------------------------------------------------------------------
216
+ # Repo tree cached in-memory and on disk (24h TTL)
217
+ # ---------------------------------------------------------------------------
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
+
226
+ _tree_cache: Optional[List[Dict[str, str]]] = None
227
+
228
+
229
+ def _tree_cache_path() -> Path:
230
+ return _cache_root() / "tree.json"
231
+
232
+
233
+ def _clear_tree_cache() -> None:
234
+ """Drop the in-memory tree cache and delete the disk cache file."""
235
+ global _tree_cache
236
+ _tree_cache = None
237
+ p = _tree_cache_path()
238
+ if p.exists():
239
+ try:
240
+ p.unlink()
241
+ except OSError:
242
+ pass
201
243
 
202
- Returns list of {name, path, type, download_url}. We avoid the Contents
203
- API because its rate limit is 60/hr unauthenticated; the tree API gives
204
- us the entire hub/ tree in one call.
244
+
245
+ def _get_tree() -> List[Dict[str, str]]:
246
+ """Return the full repo tree as a list of {path, type('blob'|'tree')}.
247
+
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.
205
250
  """
206
- if not subpath.endswith("/"):
207
- subpath = subpath + "/"
251
+ global _tree_cache
252
+ if _tree_cache is not None:
253
+ return _tree_cache
254
+
255
+ tp = _tree_cache_path()
256
+ if tp.exists():
257
+ try:
258
+ meta = json.loads(tp.read_text(encoding="utf-8"))
259
+ age = max(0.0, time.time() - float(meta.get("fetched_at", 0)))
260
+ if age < HUB_CACHE_TTL_SECS and isinstance(meta.get("tree"), list):
261
+ _tree_cache = [
262
+ {"path": str(e.get("path", "")), "type": str(e.get("type", ""))}
263
+ for e in meta["tree"] if isinstance(e, dict)
264
+ ]
265
+ return _tree_cache
266
+ except (ValueError, OSError):
267
+ pass # corrupt cache file — refetch
268
+
208
269
  url = GITHUB_TREES.format(repo=HUB_REPO, ref=HUB_REF)
209
270
  data = _http_get_json(url)
210
- if not isinstance(data, dict) or "tree" not in data:
271
+ if not isinstance(data, dict) or not isinstance(data.get("tree"), list):
211
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
+ ]
277
+
278
+ try:
279
+ _cache_root().mkdir(parents=True, exist_ok=True)
280
+ tp.write_text(
281
+ json.dumps(
282
+ {"fetched_at": time.time(), "ref": HUB_REF, "tree": _tree_cache},
283
+ ),
284
+ encoding="utf-8",
285
+ )
286
+ except OSError:
287
+ pass # disk cache is best-effort
288
+
289
+ return _tree_cache
290
+
291
+
292
+ 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
+ """
298
+ if not subpath.endswith("/"):
299
+ subpath = subpath + "/"
300
+ tree = _get_tree()
212
301
  out: List[Dict[str, str]] = []
213
- for entry in data["tree"]:
214
- if not isinstance(entry, dict):
215
- continue
216
- p = str(entry.get("path", ""))
302
+ seen = set()
303
+ for entry in tree:
304
+ p = entry["path"]
217
305
  if not p.startswith(subpath):
218
306
  continue
219
- # type is "blob" (file) or "tree" (directory)
220
- etype = "file" if entry.get("type") == "blob" else (
221
- "dir" if entry.get("type") == "tree" else ""
222
- )
223
- # Name is the part after subpath.
224
307
  name = p[len(subpath):].split("/")[0]
308
+ if not name or name in seen:
309
+ continue
310
+ seen.add(name)
311
+ is_dir = any(t["path"] == subpath + name and t["type"] == "tree" for t in tree)
225
312
  out.append({
226
313
  "name": name,
227
314
  "path": p,
228
- "type": etype,
229
- # We don't use download_url from the API; we use raw URLs.
315
+ "type": "dir" if is_dir else "file",
230
316
  "download_url": "",
231
317
  })
232
- # Deduplicate: for directory listing we only want the top-level entries.
233
- seen = set()
234
- unique: List[Dict[str, str]] = []
235
- for e in out:
236
- if e["name"] in seen:
237
- continue
238
- seen.add(e["name"])
239
- unique.append(e)
240
- return unique
241
-
242
-
243
- def _list_remote_profile_files(name: str) -> List[Dict[str, str]]:
244
- """List the actual files inside hub/<name>/ (not just top-level entries).
245
-
246
- Returns only file entries (type=file), with their full remote paths.
247
- """
248
- prefix = f"hub/{name}/"
249
- url = GITHUB_TREES.format(repo=HUB_REPO, ref=HUB_REF)
250
- data = _http_get_json(url)
251
- if not isinstance(data, dict) or "tree" not in data:
252
- raise RuntimeError(f"unexpected tree API response: {type(data).__name__}")
253
- out: List[Dict[str, str]] = []
254
- for entry in data["tree"]:
255
- if not isinstance(entry, dict):
256
- continue
257
- p = str(entry.get("path", ""))
258
- if not p.startswith(prefix):
259
- continue
260
- if entry.get("type") != "blob":
261
- continue
262
- # Filename is the last segment.
263
- fname = p[len(prefix):].split("/")[-1]
264
- out.append({"name": fname, "path": p})
265
318
  return out
266
319
 
267
320
 
268
321
  def _list_profile_names() -> List[str]:
269
322
  """List top-level profile names (directories directly under hub/).
270
323
 
271
- Cheap: uses the same tree fetch as everything else, so this is one
272
- network call at most (cached in-memory by urllib).
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.
273
326
  """
274
- url = GITHUB_TREES.format(repo=HUB_REPO, ref=HUB_REF)
275
- data = _http_get_json(url)
276
- if not isinstance(data, dict) or "tree" not in data:
277
- return []
327
+ tree = _get_tree()
278
328
  names: List[str] = []
279
329
  seen = set()
280
- for entry in data["tree"]:
281
- if not isinstance(entry, dict):
282
- continue
283
- p = str(entry.get("path", ""))
330
+ for entry in tree:
331
+ p = entry["path"]
284
332
  if not p.startswith("hub/"):
285
333
  continue
286
334
  seg = p[len("hub/"):].split("/")[0]
@@ -344,12 +392,19 @@ def _edit_distance(a: str, b: str) -> int:
344
392
  return prev[n]
345
393
 
346
394
 
347
- def _download_file(remote_path: str, local_path: Path) -> None:
348
- """Download a single file from raw.githubusercontent.com."""
349
- url = GITHUB_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=remote_path)
350
- text = _http_get_text(url)
351
- local_path.parent.mkdir(parents=True, exist_ok=True)
352
- local_path.write_text(text, encoding="utf-8")
395
+ # Every hub profile is this fixed set of files (see hub/README.md):
396
+ # 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.
401
+ _PROFILE_FILE_CANDIDATES = (
402
+ "profile.yaml",
403
+ "bridge.py",
404
+ "bridge.js",
405
+ "bridge.sh",
406
+ "README.md",
407
+ )
353
408
 
354
409
 
355
410
  # ---------------------------------------------------------------------------
@@ -363,9 +418,17 @@ def fetch_profile(
363
418
  ) -> Path:
364
419
  """Fetch a profile directory to local cache. Returns the cache path.
365
420
 
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.
425
+
366
426
  If a fresh cache exists (younger than HUB_CACHE_TTL_SECS) and refresh
367
427
  is False, returns immediately without network access.
368
428
  """
429
+ if refresh:
430
+ _clear_tree_cache()
431
+
369
432
  age = _cache_age_secs(name)
370
433
  cached = cache_dir(name)
371
434
  profile_yaml = cached / "profile.yaml"
@@ -381,11 +444,15 @@ def fetch_profile(
381
444
  else:
382
445
  on_log(f"fetching profile '{name}' from {HUB_REPO}:{HUB_REF}...")
383
446
 
384
- entries = _list_remote_profile_files(name)
385
- if not entries:
386
- # getTree succeeded (otherwise _list_remote_profile_files would have
387
- # raised HubError already). So the name is genuinely wrong — surface
388
- # the list of available names so the user can correct the typo.
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")
451
+ probe = _http_get_text_optional(probe_url)
452
+ 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.
389
456
  known: List[str] = []
390
457
  try:
391
458
  known = _list_profile_names()
@@ -405,16 +472,26 @@ def fetch_profile(
405
472
  hint="\n".join(lines),
406
473
  )
407
474
 
408
- # Clear cache, then re-download every file in the profile directory.
475
+ # Clear cache, then download the candidate file set via raw URLs.
409
476
  if cached.exists():
410
477
  shutil.rmtree(cached)
411
478
  cached.mkdir(parents=True, exist_ok=True)
412
479
 
413
- for entry in entries:
414
- local = cached / entry["name"]
415
- _download_file(entry["path"], local)
480
+ # profile.yaml already fetched via the probe.
481
+ (cached / "profile.yaml").write_text(probe, encoding="utf-8")
482
+ if on_log:
483
+ on_log(" - profile.yaml")
484
+
485
+ for fname in _PROFILE_FILE_CANDIDATES:
486
+ if fname == "profile.yaml":
487
+ continue
488
+ url = GITHUB_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/{fname}")
489
+ text = _http_get_text_optional(url)
490
+ if text is None:
491
+ continue # optional file not present for this profile
492
+ (cached / fname).write_text(text, encoding="utf-8")
416
493
  if on_log:
417
- on_log(f" - {entry['name']}")
494
+ on_log(f" - {fname}")
418
495
 
419
496
  _write_cache_meta(name)
420
497
  return cached
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentproc
3
- Version: 0.4.0
3
+ Version: 0.4.1
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
@@ -19,6 +19,7 @@ import pytest
19
19
  from agentproc import hub as hub_mod
20
20
  from agentproc.hub import (
21
21
  HUB_CACHE_TTL_SECS,
22
+ HubError,
22
23
  cache_dir,
23
24
  _cache_root,
24
25
  _cache_age_secs,
@@ -92,23 +93,33 @@ def _make_fake_http_get_json(tree=None):
92
93
 
93
94
 
94
95
  def _make_fake_http_get_text(contents=None):
95
- """Return a callable that emulates _http_get_text for raw file fetches."""
96
+ """Return a callable that emulates _http_get_text for raw file fetches.
97
+
98
+ Unmatched URLs raise a HubError(404) so that _http_get_text_optional
99
+ (which wraps _http_get_text and swallows 404) returns None — modelling
100
+ an optional profile file that doesn't exist (e.g. bridge.sh on a
101
+ non-echo profile) or a wrong profile name.
102
+ """
96
103
  contents = contents or FAKE_FILE_CONTENTS
97
104
  def fake(url, timeout=30):
98
105
  # Extract path from raw URL: .../agentproc/main/hub/echo-agent/profile.yaml
99
106
  for path, content in contents.items():
100
107
  if url.endswith(path):
101
108
  return content
102
- raise AssertionError(f"unexpected text URL: {url}")
109
+ raise HubError(f"fetch failed (HTTP 404) for {url}", status=404)
103
110
  return fake
104
111
 
105
112
 
106
113
  @pytest.fixture
107
114
  def isolated_cache(monkeypatch, tmp_path):
108
- """Redirect cache to a tmp dir."""
115
+ """Redirect cache to a tmp dir and reset the module-level tree cache."""
109
116
  cache_root = tmp_path / "cache" / "hub"
110
117
  monkeypatch.setattr(hub_mod, "_cache_root", lambda: cache_root)
111
118
  monkeypatch.setattr(hub_mod, "cache_dir", lambda name: cache_root / name)
119
+ # _tree_cache is module-global and would otherwise leak across tests
120
+ # (each test uses a different fake tree). Reset it so every test starts
121
+ # cold and _get_tree re-reads from the (fresh) fake _http_get_json.
122
+ hub_mod._clear_tree_cache()
112
123
  return tmp_path
113
124
 
114
125
 
@@ -159,9 +170,38 @@ class TestFetchProfile:
159
170
  assert "README.md" in names
160
171
  assert ".cache-meta.json" in names
161
172
 
173
+ def test_fetch_happy_path_does_not_call_trees_api(self, isolated_cache):
174
+ # `hub run` fetches profile files via raw.githubusercontent.com (CDN,
175
+ # not rate-limited). A known profile must not trigger any
176
+ # api.github.com call — that's the whole point of the rate-limit fix.
177
+ json_calls = {"n": 0}
178
+
179
+ def assert_no_json(url, timeout=30):
180
+ json_calls["n"] += 1
181
+ raise AssertionError(f"unexpected Trees API call: {url}")
182
+
183
+ with patch("agentproc.hub._http_get_json", side_effect=assert_no_json), \
184
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
185
+ hub_mod.fetch_profile("echo-agent")
186
+ assert json_calls["n"] == 0
187
+
188
+ def test_fetch_skips_optional_files_that_404(self, isolated_cache):
189
+ # claude-code has no bridge.sh in the fixtures → the 404 is swallowed
190
+ # and bridge.sh is not stored, while the four standard files are.
191
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
192
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
193
+ p = hub_mod.fetch_profile("claude-code")
194
+ names = [x.name for x in p.iterdir()]
195
+ assert "profile.yaml" in names
196
+ assert "bridge.py" in names
197
+ assert "bridge.js" in names
198
+ assert "README.md" in names
199
+ assert "bridge.sh" not in names
200
+
162
201
  def test_fetch_unknown_profile_raises(self, isolated_cache):
163
202
  empty_tree = [{"path": "hub", "type": "tree"}]
164
- with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json(empty_tree)):
203
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json(empty_tree)), \
204
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
165
205
  with pytest.raises(RuntimeError, match="not found in hub"):
166
206
  hub_mod.fetch_profile("nope")
167
207
 
@@ -186,18 +226,20 @@ class TestFetchProfile:
186
226
  assert call_count["text"] == first_text # no new file downloads
187
227
 
188
228
  def test_refresh_forces_refetch(self, isolated_cache):
189
- call_count = {"json": 0}
229
+ call_count = {"text": 0}
190
230
 
191
- def counting_json(url, timeout=30):
192
- call_count["json"] += 1
193
- return _make_fake_http_get_json()(url, timeout)
231
+ def counting_text(url, timeout=30):
232
+ call_count["text"] += 1
233
+ return _make_fake_http_get_text()(url, timeout)
194
234
 
195
- with patch("agentproc.hub._http_get_json", side_effect=counting_json), \
196
- patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
235
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
236
+ patch("agentproc.hub._http_get_text", side_effect=counting_text):
197
237
  hub_mod.fetch_profile("echo-agent")
198
- first = call_count["json"]
238
+ first_text = call_count["text"]
199
239
  hub_mod.fetch_profile("echo-agent", refresh=True)
200
- assert call_count["json"] > first
240
+ # hub run fetches files via raw URLs (CDN), not the rate-limited
241
+ # Trees API — so a refresh re-fetches the file set, not the tree.
242
+ assert call_count["text"] > first_text
201
243
 
202
244
  def test_fetch_overwrites_old_files(self, isolated_cache):
203
245
  # First fetch.
@@ -250,6 +292,24 @@ class TestListProfiles:
250
292
  assert not any(n.startswith("_") for n in names), names
251
293
  assert "_shared" not in names
252
294
 
295
+ def test_tree_disk_cached_across_calls(self, isolated_cache):
296
+ # First list_profiles hits the Trees API (json 0→1) and writes
297
+ # ~/.agentproc/cache/hub/tree.json. A second call reuses the cached
298
+ # tree and must not make another API call.
299
+ json_calls = {"n": 0}
300
+
301
+ def counting_json(url, timeout=30):
302
+ json_calls["n"] += 1
303
+ return _make_fake_http_get_json()(url, timeout)
304
+
305
+ with patch("agentproc.hub._http_get_json", side_effect=counting_json), \
306
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
307
+ hub_mod.list_profiles()
308
+ assert json_calls["n"] == 1
309
+ assert (hub_mod._cache_root() / "tree.json").exists()
310
+ hub_mod.list_profiles()
311
+ assert json_calls["n"] == 1, "second call hit the Trees API again"
312
+
253
313
 
254
314
  # ---------------------------------------------------------------------------
255
315
  # show_readme
File without changes