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.
- {agentproc-0.2.1 → agentproc-0.3.0}/PKG-INFO +1 -1
- {agentproc-0.2.1 → agentproc-0.3.0}/pyproject.toml +1 -1
- {agentproc-0.2.1 → agentproc-0.3.0}/src/agentproc/__init__.py +27 -0
- {agentproc-0.2.1 → agentproc-0.3.0}/src/agentproc/cli.py +159 -28
- agentproc-0.3.0/src/agentproc/hub.py +294 -0
- {agentproc-0.2.1 → agentproc-0.3.0}/src/agentproc.egg-info/PKG-INFO +1 -1
- {agentproc-0.2.1 → agentproc-0.3.0}/src/agentproc.egg-info/SOURCES.txt +2 -0
- agentproc-0.3.0/tests/test_hub.py +299 -0
- {agentproc-0.2.1 → agentproc-0.3.0}/setup.cfg +0 -0
- {agentproc-0.2.1 → agentproc-0.3.0}/src/agentproc/runner.py +0 -0
- {agentproc-0.2.1 → agentproc-0.3.0}/src/agentproc.egg-info/dependency_links.txt +0 -0
- {agentproc-0.2.1 → agentproc-0.3.0}/src/agentproc.egg-info/entry_points.txt +0 -0
- {agentproc-0.2.1 → agentproc-0.3.0}/src/agentproc.egg-info/top_level.txt +0 -0
- {agentproc-0.2.1 → agentproc-0.3.0}/tests/test_agentproc.py +0 -0
- {agentproc-0.2.1 → agentproc-0.3.0}/tests/test_runner.py +0 -0
|
@@ -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
|
|
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
|
-
#
|
|
277
|
+
# Hub subcommand dispatcher
|
|
265
278
|
# ---------------------------------------------------------------------------
|
|
266
279
|
|
|
267
|
-
def
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
284
|
+
if not args or args[0] in ("-h", "--help"):
|
|
285
|
+
_show_hub_help()
|
|
286
|
+
return 0
|
|
273
287
|
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|