agentproc 0.2.1__tar.gz → 0.3.0__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.2.1
3
+ Version: 0.3.0
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.2.1"
7
+ version = "0.3.0"
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" }
@@ -47,8 +47,35 @@ __all__ = [
47
47
  "load_history",
48
48
  "append_history",
49
49
  "session_file_path",
50
+ "__version__",
50
51
  ]
51
52
 
53
+
54
+ def _read_version() -> str:
55
+ try:
56
+ from importlib.metadata import version, PackageNotFoundError
57
+ try:
58
+ return version("agentproc")
59
+ except PackageNotFoundError:
60
+ pass
61
+ except ImportError:
62
+ pass
63
+ try:
64
+ from pathlib import Path
65
+ toml_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
66
+ if toml_path.exists():
67
+ import re
68
+ text = toml_path.read_text(encoding="utf-8")
69
+ m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
70
+ if m:
71
+ return m.group(1)
72
+ except Exception:
73
+ pass
74
+ return "0.0.0+unknown"
75
+
76
+
77
+ __version__ = _read_version()
78
+
52
79
  PROTOCOL_VERSION = "0.1"
53
80
 
54
81
 
@@ -25,7 +25,20 @@ from .runner import (
25
25
 
26
26
 
27
27
  def _read_pkg_version() -> str:
28
- """Read version from pyproject.toml at runtime (zero-dep)."""
28
+ """Read installed package version.
29
+
30
+ Primary: importlib.metadata (works after pip/pipx install).
31
+ Fallback: parse pyproject.toml (works in source checkout, dev mode).
32
+ """
33
+ try:
34
+ from importlib.metadata import version, PackageNotFoundError
35
+ try:
36
+ return version("agentproc")
37
+ except PackageNotFoundError:
38
+ pass
39
+ except ImportError:
40
+ pass
41
+ # Fallback for source checkout (development, not installed).
29
42
  try:
30
43
  toml_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
31
44
  if toml_path.exists():
@@ -261,56 +274,146 @@ def show_version() -> None:
261
274
 
262
275
 
263
276
  # ---------------------------------------------------------------------------
264
- # Main
277
+ # Hub subcommand dispatcher
265
278
  # ---------------------------------------------------------------------------
266
279
 
267
- def main(argv: Optional[List[str]] = None) -> int:
268
- if argv is None:
269
- argv = sys.argv[1:]
280
+ def _run_hub_subcommand(args: List[str]) -> int:
281
+ """Handle `agentproc hub <list|show|install|run> [args]`."""
282
+ from . import hub as hub_mod
270
283
 
271
- parser = build_parser()
272
- opts = parser.parse_args(argv)
284
+ if not args or args[0] in ("-h", "--help"):
285
+ _show_hub_help()
286
+ return 0
273
287
 
274
- if opts.help:
275
- show_help()
288
+ sub = args[0]
289
+ rest = args[1:]
290
+
291
+ # Common flag
292
+ refresh = "--refresh" in rest
293
+ positional = [a for a in rest if not a.startswith("--")]
294
+
295
+ def _log(msg: str) -> None:
296
+ sys.stderr.write(msg + "\n")
297
+
298
+ if sub == "list":
299
+ profiles = hub_mod.list_profiles(on_log=_log)
300
+ sys.stdout.write("Available profiles in the official hub:\n\n")
301
+ for p in profiles:
302
+ sys.stdout.write(
303
+ f" {p['name']:<15} {p['tested']:<12} {p['description'][:60]}\n"
304
+ )
305
+ sys.stdout.write('\nRun `agentproc hub run <name> -p "hi"` to use one.\n')
276
306
  return 0
277
- if opts.version:
278
- show_version()
307
+
308
+ if sub == "show":
309
+ if not positional:
310
+ sys.stderr.write("error: hub show requires a profile name\n")
311
+ return 2
312
+ readme = hub_mod.show_readme(positional[0], refresh=refresh, on_log=_log)
313
+ sys.stdout.write(readme)
314
+ if not readme.endswith("\n"):
315
+ sys.stdout.write("\n")
279
316
  return 0
280
317
 
281
- if not opts.profile:
282
- sys.stderr.write("error: --profile is required\n\n")
283
- show_help()
318
+ if sub == "install":
319
+ if not positional:
320
+ sys.stderr.write("error: hub install requires a profile name\n")
321
+ return 2
322
+ hub_mod.install_profile(positional[0], Path.cwd(), refresh=refresh, on_log=_log)
323
+ return 0
324
+
325
+ if sub == "run":
326
+ if not positional:
327
+ sys.stderr.write("error: hub run requires a profile name\n")
328
+ return 2
329
+ profile_name = positional[0]
330
+ cache_dir = hub_mod.fetch_profile(profile_name, refresh=refresh, on_log=_log)
331
+ profile_path = str(cache_dir / "profile.yaml")
332
+
333
+ # Re-parse remaining args (excluding the profile name) as runner options.
334
+ parser = build_parser()
335
+ # Drop the first positional (profile name) and --refresh from rest.
336
+ runner_args = [a for i, a in enumerate(rest) if not (
337
+ (not a.startswith("--") and i == 0) or a == "--refresh"
338
+ )]
339
+ opts = parser.parse_args(runner_args)
340
+
341
+ if not opts.prompt and not opts.stdin:
342
+ sys.stderr.write("error: hub run requires --prompt <text> or --stdin\n")
343
+ return 2
344
+
345
+ return _run_agent_with_profile(profile_path, opts)
346
+
347
+ sys.stderr.write(f"error: unknown hub subcommand: {sub}\n\n")
348
+ _show_hub_help()
349
+ return 2
350
+
351
+
352
+ def _show_hub_help() -> None:
353
+ sys.stdout.write("""agentproc hub — manage profiles from the official Hub
354
+
355
+ Usage:
356
+ agentproc hub list List all profiles in the hub
357
+ agentproc hub show <name> Show a profile's README
358
+ agentproc hub install <name> Copy a profile to the current directory
359
+ agentproc hub run <name> [run-options] Fetch (if needed) and run a profile
360
+
361
+ Hub run options (same as the regular --profile runner):
362
+ -p, --prompt <text> User message (or use --stdin)
363
+ --cwd <path> Override profile.cwd (default: current dir)
364
+ --env KEY=VALUE Extra env var (repeatable)
365
+ --session <id> Previous session id for multi-turn
366
+ --timeout <secs> Override profile.timeout_secs
367
+ --no-stream Disable streaming
368
+ --verbose / --quiet Protocol line visibility (default: verbose)
369
+ --stdin Read prompt from stdin
370
+
371
+ Common options:
372
+ --refresh Force re-fetch from GitHub (ignore cache)
373
+ -h, --help Show this help
374
+
375
+ Examples:
376
+ agentproc hub list
377
+ agentproc hub run echo-agent -p "hello"
378
+ cd ~/projects/my-app && agentproc hub run claude-code -p "explain this" --env ANTHROPIC_API_KEY=$KEY
379
+ agentproc hub show codex
380
+ agentproc hub install agy
381
+
382
+ Profiles are cached at ~/.agentproc/cache/hub/<name>/ (24h TTL).
383
+ """)
384
+
385
+
386
+ def _run_agent_with_profile(profile_path: str, opts) -> int:
387
+ """Shared runner logic for both --profile path and hub run path."""
388
+ try:
389
+ yaml_text = Path(profile_path).resolve().read_text(encoding="utf-8")
390
+ profile_raw = parse_yaml(yaml_text)
391
+ except FileNotFoundError:
392
+ sys.stderr.write(f"error: profile not found: {profile_path}\n")
393
+ return 2
394
+ except Exception as e:
395
+ sys.stderr.write(f"error: failed to parse profile {profile_path}: {e}\n")
284
396
  return 2
285
397
 
398
+ # Read prompt.
286
399
  prompt = opts.prompt
287
400
  if opts.stdin:
288
401
  try:
289
402
  prompt = sys.stdin.read().rstrip("\n")
290
403
  except KeyboardInterrupt:
291
- return EXIT_ERROR
404
+ return 1
292
405
  if prompt is None:
293
406
  sys.stderr.write("error: --prompt (or --stdin) is required\n")
294
407
  return 2
295
408
 
296
409
  extra_env: Dict[str, str] = {}
297
- for kv in opts.env:
410
+ for kv in opts.env or []:
298
411
  eq = kv.find("=")
299
412
  if eq < 0:
300
413
  sys.stderr.write(f"error: --env expects KEY=VALUE, got: {kv}\n")
301
414
  return 2
302
415
  extra_env[kv[:eq]] = kv[eq + 1:]
303
416
 
304
- try:
305
- yaml_text = Path(opts.profile).resolve().read_text(encoding="utf-8")
306
- profile_raw = parse_yaml(yaml_text)
307
- except FileNotFoundError:
308
- sys.stderr.write(f"error: profile not found: {opts.profile}\n")
309
- return 2
310
- except Exception as e:
311
- sys.stderr.write(f"error: failed to parse profile {opts.profile}: {e}\n")
312
- return 2
313
-
314
417
  streaming = False if opts.no_stream else None
315
418
 
316
419
  if opts.raw:
@@ -360,14 +463,42 @@ def main(argv: Optional[List[str]] = None) -> int:
360
463
  sys.stdout.write(r.reply)
361
464
  if not r.reply.endswith("\n"):
362
465
  sys.stdout.write("\n")
363
-
364
466
  if r.session_id:
365
467
  sys.stderr.write(f"agentproc:session:{r.session_id}\n")
366
468
  if r.error:
367
469
  sys.stderr.write(f"agentproc:error:{r.error}\n")
368
-
369
470
  return 0 if r.exit_code == 0 else 1
370
471
 
371
472
 
473
+ # ---------------------------------------------------------------------------
474
+ # Main
475
+ # ---------------------------------------------------------------------------
476
+
477
+ def main(argv: Optional[List[str]] = None) -> int:
478
+ if argv is None:
479
+ argv = sys.argv[1:]
480
+
481
+ # `agentproc hub <subcommand>` — defer to hub dispatcher.
482
+ if argv and argv[0] == "hub":
483
+ return _run_hub_subcommand(argv[1:])
484
+
485
+ parser = build_parser()
486
+ opts = parser.parse_args(argv)
487
+
488
+ if opts.help:
489
+ show_help()
490
+ return 0
491
+ if opts.version:
492
+ show_version()
493
+ return 0
494
+
495
+ if not opts.profile:
496
+ sys.stderr.write("error: --profile is required\n\n")
497
+ show_help()
498
+ return 2
499
+
500
+ return _run_agent_with_profile(opts.profile, opts)
501
+
502
+
372
503
  if __name__ == "__main__":
373
504
  sys.exit(main())
@@ -0,0 +1,294 @@
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
+ Profiles are cached locally at ~/.agentproc/cache/hub/<name>/ with a
5
+ 24-hour TTL. Pass refresh=True to force re-fetch.
6
+
7
+ Public API:
8
+ HUB_REPO — the github repo id ("jeffkit/agentproc")
9
+ HUB_REF — the git ref to fetch from ("main")
10
+ HUB_CACHE_TTL_SECS — default 24 hours
11
+ cache_dir(name) — Path to the local cache directory for a profile
12
+ fetch_profile(name, refresh=False, on_log=None) -> Path
13
+ list_profiles(refresh=False, on_log=None) -> List[Dict[str,str]]
14
+ show_readme(name, refresh=False, on_log=None) -> str
15
+ install_profile(name, target_dir, refresh=False, on_log=None) -> Path
16
+
17
+ All network access is via urllib (stdlib). Zero dependencies.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import shutil
25
+ import sys
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from pathlib import Path
30
+ from typing import Callable, Dict, List, Optional
31
+
32
+ HUB_REPO = "jeffkit/agentproc"
33
+ HUB_REF = "main"
34
+ HUB_CACHE_TTL_SECS = 24 * 60 * 60 # 24 hours
35
+
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}"
39
+
40
+
41
+ def _cache_root() -> Path:
42
+ """Root cache directory: ~/.agentproc/cache/hub/"""
43
+ return Path.home() / ".agentproc" / "cache" / "hub"
44
+
45
+
46
+ def cache_dir(name: str) -> Path:
47
+ """Local cache path for a profile."""
48
+ return _cache_root() / name
49
+
50
+
51
+ def _cache_age_secs(name: str) -> Optional[float]:
52
+ """Seconds since the cache was last written. None if not cached."""
53
+ p = cache_dir(name)
54
+ marker = p / ".cache-meta.json"
55
+ if not marker.exists():
56
+ return None
57
+ try:
58
+ meta = json.loads(marker.read_text(encoding="utf-8"))
59
+ ts = meta.get("fetched_at", 0)
60
+ return max(0, time.time() - ts)
61
+ except Exception:
62
+ return None
63
+
64
+
65
+ def _write_cache_meta(name: str) -> None:
66
+ """Write a small metadata file recording when we fetched."""
67
+ p = cache_dir(name)
68
+ p.mkdir(parents=True, exist_ok=True)
69
+ (p / ".cache-meta.json").write_text(
70
+ json.dumps({"fetched_at": time.time(), "ref": HUB_REF}),
71
+ encoding="utf-8",
72
+ )
73
+
74
+
75
+ def _http_get_json(url: str, timeout: int = 30) -> Any:
76
+ """GET a URL, return parsed JSON. Raises urllib.error.HTTPError on failure."""
77
+ req = urllib.request.Request(url, headers={
78
+ "Accept": "application/vnd.github+json",
79
+ "User-Agent": "agentproc-cli",
80
+ })
81
+ with urllib.request.urlopen(req, timeout=timeout) as r:
82
+ return json.loads(r.read().decode("utf-8"))
83
+
84
+
85
+ def _http_get_text(url: str, timeout: int = 30) -> str:
86
+ req = urllib.request.Request(url, headers={"User-Agent": "agentproc-cli"})
87
+ with urllib.request.urlopen(req, timeout=timeout) as r:
88
+ return r.read().decode("utf-8")
89
+
90
+
91
+ def _list_remote_files(subpath: str) -> List[Dict[str, str]]:
92
+ """List files in a hub subpath via the git tree API (1 API call for all).
93
+
94
+ Returns list of {name, path, type, download_url}. We avoid the Contents
95
+ API because its rate limit is 60/hr unauthenticated; the tree API gives
96
+ us the entire hub/ tree in one call.
97
+ """
98
+ if not subpath.endswith("/"):
99
+ subpath = subpath + "/"
100
+ url = GITHUB_TREES.format(repo=HUB_REPO, ref=HUB_REF)
101
+ data = _http_get_json(url)
102
+ if not isinstance(data, dict) or "tree" not in data:
103
+ raise RuntimeError(f"unexpected tree API response: {type(data).__name__}")
104
+ out: List[Dict[str, str]] = []
105
+ for entry in data["tree"]:
106
+ if not isinstance(entry, dict):
107
+ continue
108
+ p = str(entry.get("path", ""))
109
+ if not p.startswith(subpath):
110
+ continue
111
+ # type is "blob" (file) or "tree" (directory)
112
+ etype = "file" if entry.get("type") == "blob" else (
113
+ "dir" if entry.get("type") == "tree" else ""
114
+ )
115
+ # Name is the part after subpath.
116
+ name = p[len(subpath):].split("/")[0]
117
+ out.append({
118
+ "name": name,
119
+ "path": p,
120
+ "type": etype,
121
+ # We don't use download_url from the API; we use raw URLs.
122
+ "download_url": "",
123
+ })
124
+ # Deduplicate: for directory listing we only want the top-level entries.
125
+ seen = set()
126
+ unique: List[Dict[str, str]] = []
127
+ for e in out:
128
+ if e["name"] in seen:
129
+ continue
130
+ seen.add(e["name"])
131
+ unique.append(e)
132
+ return unique
133
+
134
+
135
+ def _list_remote_profile_files(name: str) -> List[Dict[str, str]]:
136
+ """List the actual files inside hub/<name>/ (not just top-level entries).
137
+
138
+ Returns only file entries (type=file), with their full remote paths.
139
+ """
140
+ prefix = f"hub/{name}/"
141
+ url = GITHUB_TREES.format(repo=HUB_REPO, ref=HUB_REF)
142
+ data = _http_get_json(url)
143
+ if not isinstance(data, dict) or "tree" not in data:
144
+ raise RuntimeError(f"unexpected tree API response: {type(data).__name__}")
145
+ out: List[Dict[str, str]] = []
146
+ for entry in data["tree"]:
147
+ if not isinstance(entry, dict):
148
+ continue
149
+ p = str(entry.get("path", ""))
150
+ if not p.startswith(prefix):
151
+ continue
152
+ if entry.get("type") != "blob":
153
+ continue
154
+ # Filename is the last segment.
155
+ fname = p[len(prefix):].split("/")[-1]
156
+ out.append({"name": fname, "path": p})
157
+ return out
158
+
159
+
160
+ def _download_file(remote_path: str, local_path: Path) -> None:
161
+ """Download a single file from raw.githubusercontent.com."""
162
+ url = GITHUB_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=remote_path)
163
+ text = _http_get_text(url)
164
+ local_path.parent.mkdir(parents=True, exist_ok=True)
165
+ local_path.write_text(text, encoding="utf-8")
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Public API
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def fetch_profile(
173
+ name: str,
174
+ refresh: bool = False,
175
+ on_log: Optional[Callable[[str], None]] = None,
176
+ ) -> Path:
177
+ """Fetch a profile directory to local cache. Returns the cache path.
178
+
179
+ If a fresh cache exists (younger than HUB_CACHE_TTL_SECS) and refresh
180
+ is False, returns immediately without network access.
181
+ """
182
+ age = _cache_age_secs(name)
183
+ cached = cache_dir(name)
184
+ profile_yaml = cached / "profile.yaml"
185
+
186
+ if not refresh and age is not None and age < HUB_CACHE_TTL_SECS and profile_yaml.exists():
187
+ if on_log:
188
+ on_log(f"using cached profile: {cached} (age {int(age)}s)")
189
+ return cached
190
+
191
+ if on_log:
192
+ if refresh:
193
+ on_log(f"refreshing profile '{name}' from {HUB_REPO}:{HUB_REF}...")
194
+ else:
195
+ on_log(f"fetching profile '{name}' from {HUB_REPO}:{HUB_REF}...")
196
+
197
+ entries = _list_remote_profile_files(name)
198
+ if not entries:
199
+ raise RuntimeError(f"profile '{name}' not found in hub")
200
+
201
+ # Clear cache, then re-download every file in the profile directory.
202
+ if cached.exists():
203
+ shutil.rmtree(cached)
204
+ cached.mkdir(parents=True, exist_ok=True)
205
+
206
+ for entry in entries:
207
+ local = cached / entry["name"]
208
+ _download_file(entry["path"], local)
209
+ if on_log:
210
+ on_log(f" - {entry['name']}")
211
+
212
+ _write_cache_meta(name)
213
+ return cached
214
+
215
+
216
+ def list_profiles(
217
+ refresh: bool = False,
218
+ on_log: Optional[Callable[[str], None]] = None,
219
+ ) -> List[Dict[str, str]]:
220
+ """List profiles in the official hub.
221
+
222
+ Returns list of dicts: {name, description, cli, tested}.
223
+ """
224
+ entries = _list_remote_files("hub")
225
+ profiles: List[Dict[str, str]] = []
226
+ for entry in entries:
227
+ if entry["type"] != "dir":
228
+ continue
229
+ name = entry["name"]
230
+ # Read profile.yaml from raw URL to get metadata.
231
+ try:
232
+ yaml_url = GITHUB_RAW.format(
233
+ repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/profile.yaml"
234
+ )
235
+ yaml_text = _http_get_text(yaml_url)
236
+ from .cli import parse_yaml # local import to avoid cycle
237
+ data = parse_yaml(yaml_text)
238
+ profiles.append({
239
+ "name": str(data.get("name", name)),
240
+ "description": str(data.get("description", "")),
241
+ "cli": str(data.get("cli", "")),
242
+ "tested": str(data.get("tested", "unverified")),
243
+ })
244
+ except Exception as e:
245
+ if on_log:
246
+ on_log(f"warning: could not read metadata for {name}: {e}")
247
+ profiles.append({
248
+ "name": name,
249
+ "description": "(failed to read metadata)",
250
+ "cli": "",
251
+ "tested": "unverified",
252
+ })
253
+ return profiles
254
+
255
+
256
+ def show_readme(
257
+ name: str,
258
+ refresh: bool = False,
259
+ on_log: Optional[Callable[[str], None]] = None,
260
+ ) -> str:
261
+ """Return the README.md content for a profile (fetches if needed)."""
262
+ cached = fetch_profile(name, refresh=refresh, on_log=on_log)
263
+ readme = cached / "README.md"
264
+ if not readme.exists():
265
+ return f"(no README.md for profile '{name}')"
266
+ return readme.read_text(encoding="utf-8")
267
+
268
+
269
+ def install_profile(
270
+ name: str,
271
+ target_dir: Path,
272
+ refresh: bool = False,
273
+ on_log: Optional[Callable[[str], None]] = None,
274
+ ) -> Path:
275
+ """Copy a cached profile into target_dir/<name>/.
276
+
277
+ Useful when the user wants to own and edit the profile locally.
278
+ """
279
+ cached = fetch_profile(name, refresh=refresh, on_log=on_log)
280
+ dest = Path(target_dir) / name
281
+ if dest.exists():
282
+ raise RuntimeError(f"target already exists: {dest}")
283
+ shutil.copytree(cached, dest)
284
+ # Don't copy our cache meta file.
285
+ meta = dest / ".cache-meta.json"
286
+ if meta.exists():
287
+ meta.unlink()
288
+ if on_log:
289
+ on_log(f"installed to: {dest}")
290
+ return dest
291
+
292
+
293
+ # Re-export for type checker
294
+ from typing import Any # noqa: E402
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentproc
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
@@ -1,6 +1,7 @@
1
1
  pyproject.toml
2
2
  src/agentproc/__init__.py
3
3
  src/agentproc/cli.py
4
+ src/agentproc/hub.py
4
5
  src/agentproc/runner.py
5
6
  src/agentproc.egg-info/PKG-INFO
6
7
  src/agentproc.egg-info/SOURCES.txt
@@ -8,4 +9,5 @@ src/agentproc.egg-info/dependency_links.txt
8
9
  src/agentproc.egg-info/entry_points.txt
9
10
  src/agentproc.egg-info/top_level.txt
10
11
  tests/test_agentproc.py
12
+ tests/test_hub.py
11
13
  tests/test_runner.py
@@ -0,0 +1,299 @@
1
+ """Tests for agentproc.hub.
2
+
3
+ Mock-based — no real network access. Covers:
4
+ - Tree API response parsing
5
+ - Local cache TTL logic
6
+ - list/show/install/run operations
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List
15
+ from unittest.mock import patch
16
+
17
+ import pytest
18
+
19
+ from agentproc import hub as hub_mod
20
+ from agentproc.hub import (
21
+ HUB_CACHE_TTL_SECS,
22
+ cache_dir,
23
+ _cache_root,
24
+ _cache_age_secs,
25
+ _write_cache_meta,
26
+ )
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Test fixtures: synthetic GitHub tree + file contents
31
+ # ---------------------------------------------------------------------------
32
+
33
+ FAKE_TREE = [
34
+ {"path": "hub", "type": "tree"},
35
+ {"path": "hub/echo-agent", "type": "tree"},
36
+ {"path": "hub/echo-agent/profile.yaml", "type": "blob"},
37
+ {"path": "hub/echo-agent/bridge.py", "type": "blob"},
38
+ {"path": "hub/echo-agent/bridge.js", "type": "blob"},
39
+ {"path": "hub/echo-agent/bridge.sh", "type": "blob"},
40
+ {"path": "hub/echo-agent/README.md", "type": "blob"},
41
+ {"path": "hub/claude-code", "type": "tree"},
42
+ {"path": "hub/claude-code/profile.yaml", "type": "blob"},
43
+ {"path": "hub/claude-code/bridge.py", "type": "blob"},
44
+ {"path": "hub/claude-code/bridge.js", "type": "blob"},
45
+ {"path": "hub/claude-code/README.md", "type": "blob"},
46
+ {"path": "README.md", "type": "blob"},
47
+ {"path": "spec/protocol.md", "type": "blob"},
48
+ ]
49
+
50
+ FAKE_FILE_CONTENTS = {
51
+ "hub/echo-agent/profile.yaml": (
52
+ "name: echo-agent\n"
53
+ "description: Minimal hello-world agent\n"
54
+ "cli: none\n"
55
+ "agentproc:\n"
56
+ " command: python3 ./bridge.py\n"
57
+ " cwd: .\n"
58
+ "tested: official\n"
59
+ "maintainer: jeffkit\n"
60
+ ),
61
+ "hub/echo-agent/bridge.py": "#!/usr/bin/env python3\nprint('echo')\n",
62
+ "hub/echo-agent/bridge.js": "'use strict';\nconsole.log('echo');\n",
63
+ "hub/echo-agent/bridge.sh": "#!/usr/bin/env bash\necho echo\n",
64
+ "hub/echo-agent/README.md": "# echo-agent\n\nHello world.\n",
65
+ "hub/claude-code/profile.yaml": (
66
+ "name: claude-code\n"
67
+ "description: Claude Code wrapper\n"
68
+ "cli: claude\n"
69
+ "agentproc:\n"
70
+ " command: python3 ./bridge.py\n"
71
+ "tested: official\n"
72
+ "maintainer: jeffkit\n"
73
+ ),
74
+ "hub/claude-code/bridge.py": "#!/usr/bin/env python3\nprint('claude')\n",
75
+ "hub/claude-code/bridge.js": "'use strict';\nconsole.log('claude');\n",
76
+ "hub/claude-code/README.md": "# claude-code\n\nReal wrapper.\n",
77
+ }
78
+
79
+
80
+ def _make_fake_http_get_json(tree=None):
81
+ """Return a callable that emulates _http_get_json for the tree API."""
82
+ tree = tree if tree is not None else FAKE_TREE
83
+ def fake(url, timeout=30):
84
+ if "git/trees" in url:
85
+ return {"tree": tree}
86
+ raise AssertionError(f"unexpected JSON URL: {url}")
87
+ return fake
88
+
89
+
90
+ def _make_fake_http_get_text(contents=None):
91
+ """Return a callable that emulates _http_get_text for raw file fetches."""
92
+ contents = contents or FAKE_FILE_CONTENTS
93
+ def fake(url, timeout=30):
94
+ # Extract path from raw URL: .../agentproc/main/hub/echo-agent/profile.yaml
95
+ for path, content in contents.items():
96
+ if url.endswith(path):
97
+ return content
98
+ raise AssertionError(f"unexpected text URL: {url}")
99
+ return fake
100
+
101
+
102
+ @pytest.fixture
103
+ def isolated_cache(monkeypatch, tmp_path):
104
+ """Redirect cache to a tmp dir."""
105
+ cache_root = tmp_path / "cache" / "hub"
106
+ monkeypatch.setattr(hub_mod, "_cache_root", lambda: cache_root)
107
+ monkeypatch.setattr(hub_mod, "cache_dir", lambda name: cache_root / name)
108
+ return tmp_path
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Cache helpers
113
+ # ---------------------------------------------------------------------------
114
+
115
+ class TestCacheHelpers:
116
+ def test_cache_age_none_when_not_cached(self, isolated_cache):
117
+ assert _cache_age_secs("never-cached") is None
118
+
119
+ def test_cache_age_after_write(self, isolated_cache):
120
+ _write_cache_meta("foo")
121
+ age = _cache_age_secs("foo")
122
+ assert age is not None
123
+ assert age < 5 # just written
124
+
125
+ def test_cache_age_old(self, isolated_cache, tmp_path):
126
+ # Manually write an old marker.
127
+ marker = isolated_cache / "cache" / "hub" / "old" / ".cache-meta.json"
128
+ marker.parent.mkdir(parents=True, exist_ok=True)
129
+ marker.write_text(json.dumps({"fetched_at": time.time() - 100000, "ref": "main"}))
130
+ age = _cache_age_secs("old")
131
+ assert age is not None
132
+ assert age > 100000
133
+
134
+ def test_cache_age_invalid_meta(self, isolated_cache):
135
+ marker = isolated_cache / "cache" / "hub" / "bad" / ".cache-meta.json"
136
+ marker.parent.mkdir(parents=True, exist_ok=True)
137
+ marker.write_text("not json at all")
138
+ assert _cache_age_secs("bad") is None
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # fetch_profile
143
+ # ---------------------------------------------------------------------------
144
+
145
+ class TestFetchProfile:
146
+ def test_fetch_downloads_all_files(self, isolated_cache):
147
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
148
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
149
+ p = hub_mod.fetch_profile("echo-agent", on_log=lambda m: None)
150
+ assert p.exists()
151
+ names = sorted(x.name for x in p.iterdir())
152
+ assert "profile.yaml" in names
153
+ assert "bridge.py" in names
154
+ assert "bridge.js" in names
155
+ assert "README.md" in names
156
+ assert ".cache-meta.json" in names
157
+
158
+ def test_fetch_unknown_profile_raises(self, isolated_cache):
159
+ empty_tree = [{"path": "hub", "type": "tree"}]
160
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json(empty_tree)):
161
+ with pytest.raises(RuntimeError, match="not found in hub"):
162
+ hub_mod.fetch_profile("nope")
163
+
164
+ def test_fetch_uses_cache_on_second_call(self, isolated_cache):
165
+ call_count = {"json": 0, "text": 0}
166
+
167
+ def counting_json(url, timeout=30):
168
+ call_count["json"] += 1
169
+ return _make_fake_http_get_json()(url, timeout)
170
+
171
+ def counting_text(url, timeout=30):
172
+ call_count["text"] += 1
173
+ return _make_fake_http_get_text()(url, timeout)
174
+
175
+ with patch("agentproc.hub._http_get_json", side_effect=counting_json), \
176
+ patch("agentproc.hub._http_get_text", side_effect=counting_text):
177
+ hub_mod.fetch_profile("echo-agent")
178
+ first_json = call_count["json"]
179
+ first_text = call_count["text"]
180
+ hub_mod.fetch_profile("echo-agent") # should hit cache
181
+ assert call_count["json"] == first_json # no new API calls
182
+ assert call_count["text"] == first_text # no new file downloads
183
+
184
+ def test_refresh_forces_refetch(self, isolated_cache):
185
+ call_count = {"json": 0}
186
+
187
+ def counting_json(url, timeout=30):
188
+ call_count["json"] += 1
189
+ return _make_fake_http_get_json()(url, timeout)
190
+
191
+ with patch("agentproc.hub._http_get_json", side_effect=counting_json), \
192
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
193
+ hub_mod.fetch_profile("echo-agent")
194
+ first = call_count["json"]
195
+ hub_mod.fetch_profile("echo-agent", refresh=True)
196
+ assert call_count["json"] > first
197
+
198
+ def test_fetch_overwrites_old_files(self, isolated_cache):
199
+ # First fetch.
200
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
201
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
202
+ hub_mod.fetch_profile("echo-agent")
203
+ # Modify cache.
204
+ cached_file = cache_dir("echo-agent") / "bridge.py"
205
+ original = cached_file.read_text()
206
+ cached_file.write_text("# tampered\n")
207
+ # Refresh — should restore original.
208
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
209
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
210
+ hub_mod.fetch_profile("echo-agent", refresh=True)
211
+ assert cached_file.read_text() == original
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # list_profiles
216
+ # ---------------------------------------------------------------------------
217
+
218
+ class TestListProfiles:
219
+ def test_list_returns_all_dirs(self, isolated_cache):
220
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
221
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
222
+ profiles = hub_mod.list_profiles()
223
+ names = sorted(p["name"] for p in profiles)
224
+ assert names == ["claude-code", "echo-agent"]
225
+ ec = next(p for p in profiles if p["name"] == "echo-agent")
226
+ assert ec["tested"] == "official"
227
+ assert ec["description"] == "Minimal hello-world agent"
228
+ assert ec["cli"] == "none"
229
+
230
+ def test_list_skips_non_hub_paths(self, isolated_cache):
231
+ """The repo root README.md and spec/ should not appear in hub listing."""
232
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
233
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
234
+ profiles = hub_mod.list_profiles()
235
+ # No root-level files leaked.
236
+ for p in profiles:
237
+ assert p["name"].startswith("") # just a sanity check
238
+ assert "/" not in p["name"]
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # show_readme
243
+ # ---------------------------------------------------------------------------
244
+
245
+ class TestShowReadme:
246
+ def test_returns_readme_content(self, isolated_cache):
247
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
248
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
249
+ text = hub_mod.show_readme("echo-agent")
250
+ assert "echo-agent" in text
251
+ assert "Hello world" in text
252
+
253
+ def test_missing_readme_returns_placeholder(self, isolated_cache):
254
+ # Fetch a profile with no README.
255
+ tree = [
256
+ {"path": "hub/noreadme", "type": "tree"},
257
+ {"path": "hub/noreadme/profile.yaml", "type": "blob"},
258
+ ]
259
+ contents = {"hub/noreadme/profile.yaml": "name: noreadme\ndescription: x\ntested: unverified\n"}
260
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json(tree)), \
261
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text(contents)):
262
+ text = hub_mod.show_readme("noreadme")
263
+ assert "no README.md" in text
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # install_profile
268
+ # ---------------------------------------------------------------------------
269
+
270
+ class TestInstallProfile:
271
+ def test_install_copies_to_target(self, isolated_cache, tmp_path):
272
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
273
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
274
+ dest = hub_mod.install_profile("echo-agent", tmp_path)
275
+ assert dest.exists()
276
+ assert (dest / "profile.yaml").exists()
277
+ assert (dest / "bridge.py").exists()
278
+ # Cache meta should NOT be copied to the installed copy.
279
+ assert not (dest / ".cache-meta.json").exists()
280
+
281
+ def test_install_refuses_existing_target(self, isolated_cache, tmp_path):
282
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
283
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
284
+ hub_mod.install_profile("echo-agent", tmp_path)
285
+ with pytest.raises(RuntimeError, match="target already exists"):
286
+ hub_mod.install_profile("echo-agent", tmp_path)
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Constants
291
+ # ---------------------------------------------------------------------------
292
+
293
+ def test_hub_cache_ttl_is_24h():
294
+ assert HUB_CACHE_TTL_SECS == 24 * 60 * 60
295
+
296
+
297
+ def test_hub_repo_constants():
298
+ assert hub_mod.HUB_REPO == "jeffkit/agentproc"
299
+ assert hub_mod.HUB_REF == "main"
File without changes