agentproc 0.3.0__tar.gz → 0.4.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.3.0
3
+ Version: 0.4.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.3.0"
7
+ version = "0.4.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" }
@@ -227,6 +227,27 @@ def build_parser() -> argparse.ArgumentParser:
227
227
  def show_help() -> None:
228
228
  sys.stdout.write(f"""agentproc v{PKG_VERSION} (protocol {PROTOCOL_VERSION})
229
229
 
230
+ The fastest way in:
231
+ agentproc hub list # see what's available
232
+ agentproc hub run echo-agent -p "hello" # smoke test (no API key)
233
+ cd ~/projects/my-app && agentproc hub run claude-code -p "explain this"
234
+
235
+ The CLI fetches the profile from the GitHub hub on first use, caches it at
236
+ ~/.agentproc/cache/hub/<name>/ (24h TTL), and uses your current directory as
237
+ the agent's cwd. Set GITHUB_TOKEN to raise the rate limit (see `agentproc hub --help`).
238
+
239
+ Hub subcommands:
240
+ hub list List all profiles in the hub
241
+ hub show <name> Show a profile's README
242
+ hub run <name> [run-options] Fetch (if needed) and run a profile
243
+ hub install <name> Copy a profile to the current directory
244
+
245
+ Run `agentproc hub --help` for the full hub reference.
246
+
247
+ ───────────────────────────────────────────────────────────────────────────────
248
+
249
+ Advanced: run a local profile YAML directly (no hub fetch)
250
+
230
251
  Usage:
231
252
  agentproc --profile <path.yaml> --prompt "hello" [options]
232
253
 
@@ -240,7 +261,8 @@ Session:
240
261
  --from <user> Sender identifier
241
262
 
242
263
  Execution:
243
- --cwd <path> Override profile.cwd
264
+ --cwd <path> Override profile.cwd (relative paths resolve
265
+ against the profile.yaml's directory)
244
266
  --env KEY=VALUE Extra env var (repeatable)
245
267
  --timeout <secs> Override profile.timeout_secs
246
268
  --no-stream Set AGENT_STREAMING=0
@@ -263,9 +285,16 @@ Output semantics:
263
285
  The final session id is printed on stderr as: agentproc:session:<id>
264
286
 
265
287
  Examples:
266
- agentproc --profile hub/echo-agent/profile.yaml --prompt "hi"
267
- agentproc -p hub/claude-code/profile.yaml --prompt "hello" --verbose
268
- cat prompt.txt | agentproc -p prof.yaml --stdin
288
+ # Local profile (relative cwd resolves next to profile.yaml):
289
+ agentproc --profile ./hub/echo-agent/profile.yaml --prompt "hi"
290
+
291
+ # Local claude-code profile, claude runs against your project:
292
+ agentproc --profile ./hub/claude-code/profile.yaml \\
293
+ --prompt "explain this codebase" \\
294
+ --cwd /path/to/your/project
295
+
296
+ # Prompt from stdin:
297
+ cat prompt.txt | agentproc --profile prof.yaml --stdin
269
298
  """)
270
299
 
271
300
 
@@ -288,9 +317,46 @@ def _run_hub_subcommand(args: List[str]) -> int:
288
317
  sub = args[0]
289
318
  rest = args[1:]
290
319
 
320
+ # Any subcommand with --help/-h shows the hub help uniformly.
321
+ if "--help" in rest or "-h" in rest:
322
+ _show_hub_help()
323
+ return 0
324
+
291
325
  # Common flag
292
326
  refresh = "--refresh" in rest
293
- positional = [a for a in rest if not a.startswith("--")]
327
+
328
+ # Separate hub-level flags from runner-level flags and positional args.
329
+ # In the `hub run` context, `-p` means `--prompt` (the profile name is
330
+ # positional, not a path), so normalize it before handing off to argparse.
331
+ positional: List[str] = []
332
+ runner_args: List[str] = []
333
+ takes_value = {"--prompt", "-p", "--session", "--session-name", "--from",
334
+ "--cwd", "--env", "--timeout"}
335
+ boolean_flags = {"--no-stream", "--verbose", "--quiet", "--raw", "--stdin"}
336
+ i = 0
337
+ while i < len(rest):
338
+ a = rest[i]
339
+ if a in ("--refresh", "-h", "--help"):
340
+ i += 1
341
+ continue
342
+ if a in takes_value:
343
+ runner_args.append("--prompt" if a == "-p" else a)
344
+ if i + 1 < len(rest):
345
+ runner_args.append(rest[i + 1])
346
+ i += 2
347
+ else:
348
+ i += 1
349
+ continue
350
+ if a in boolean_flags:
351
+ runner_args.append(a)
352
+ i += 1
353
+ continue
354
+ if a.startswith("-"):
355
+ sys.stderr.write(f"error: unknown option: {a}\n\n")
356
+ _show_hub_help()
357
+ return 2
358
+ positional.append(a)
359
+ i += 1
294
360
 
295
361
  def _log(msg: str) -> None:
296
362
  sys.stderr.write(msg + "\n")
@@ -319,7 +385,14 @@ def _run_hub_subcommand(args: List[str]) -> int:
319
385
  if not positional:
320
386
  sys.stderr.write("error: hub install requires a profile name\n")
321
387
  return 2
322
- hub_mod.install_profile(positional[0], Path.cwd(), refresh=refresh, on_log=_log)
388
+ dest = hub_mod.install_profile(positional[0], Path.cwd(), refresh=refresh, on_log=_log)
389
+ # Tell the user exactly what they got and how to run it next.
390
+ rel_profile = os.path.relpath(str(dest / "profile.yaml"), str(Path.cwd()))
391
+ sys.stderr.write("\n")
392
+ sys.stderr.write(f"Next: edit {rel_profile} if you want, then run:\n")
393
+ sys.stderr.write(
394
+ f' agentproc --profile {rel_profile} --prompt "hi" --cwd <your-project>\n'
395
+ )
323
396
  return 0
324
397
 
325
398
  if sub == "run":
@@ -330,18 +403,20 @@ def _run_hub_subcommand(args: List[str]) -> int:
330
403
  cache_dir = hub_mod.fetch_profile(profile_name, refresh=refresh, on_log=_log)
331
404
  profile_path = str(cache_dir / "profile.yaml")
332
405
 
333
- # Re-parse remaining args (excluding the profile name) as runner options.
406
+ # Parse the runner-level flags we separated out above.
334
407
  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
408
  opts = parser.parse_args(runner_args)
340
409
 
341
410
  if not opts.prompt and not opts.stdin:
342
411
  sys.stderr.write("error: hub run requires --prompt <text> or --stdin\n")
343
412
  return 2
344
413
 
414
+ # hub run uses the user's current directory as the agent's cwd when
415
+ # --cwd is not given. Matches the hub docs and the right default for
416
+ # AI-CLI profiles where the agent should operate on the user's project.
417
+ if not opts.cwd:
418
+ opts.cwd = str(Path.cwd())
419
+
345
420
  return _run_agent_with_profile(profile_path, opts)
346
421
 
347
422
  sys.stderr.write(f"error: unknown hub subcommand: {sub}\n\n")
@@ -385,6 +460,7 @@ Profiles are cached at ~/.agentproc/cache/hub/<name>/ (24h TTL).
385
460
 
386
461
  def _run_agent_with_profile(profile_path: str, opts) -> int:
387
462
  """Shared runner logic for both --profile path and hub run path."""
463
+ profile_dir = str(Path(profile_path).resolve().parent)
388
464
  try:
389
465
  yaml_text = Path(profile_path).resolve().read_text(encoding="utf-8")
390
466
  profile_raw = parse_yaml(yaml_text)
@@ -426,6 +502,7 @@ def _run_agent_with_profile(profile_path: str, opts) -> int:
426
502
  from_user=opts.from_user,
427
503
  streaming=streaming,
428
504
  cwd=opts.cwd,
505
+ profile_dir=profile_dir,
429
506
  extra_env=extra_env,
430
507
  timeout_secs=opts.timeout,
431
508
  ),
@@ -446,6 +523,7 @@ def _run_agent_with_profile(profile_path: str, opts) -> int:
446
523
  from_user=opts.from_user,
447
524
  streaming=streaming,
448
525
  cwd=opts.cwd,
526
+ profile_dir=profile_dir,
449
527
  extra_env=extra_env,
450
528
  timeout_secs=opts.timeout,
451
529
  on_partial=lambda t: verbose and sys.stderr.write(
@@ -478,26 +556,48 @@ def main(argv: Optional[List[str]] = None) -> int:
478
556
  if argv is None:
479
557
  argv = sys.argv[1:]
480
558
 
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
559
+ try:
560
+ # `agentproc hub <subcommand>` defer to hub dispatcher.
561
+ if argv and argv[0] == "hub":
562
+ return _run_hub_subcommand(argv[1:])
494
563
 
495
- if not opts.profile:
496
- sys.stderr.write("error: --profile is required\n\n")
497
- show_help()
498
- return 2
564
+ parser = build_parser()
565
+ opts = parser.parse_args(argv)
566
+
567
+ if opts.help:
568
+ show_help()
569
+ return 0
570
+ if opts.version:
571
+ show_version()
572
+ return 0
573
+
574
+ if not opts.profile:
575
+ sys.stderr.write("error: --profile is required\n\n")
576
+ show_help()
577
+ return 2
499
578
 
500
- return _run_agent_with_profile(opts.profile, opts)
579
+ return _run_agent_with_profile(opts.profile, opts)
580
+ except KeyboardInterrupt:
581
+ return 130
582
+ except Exception as e:
583
+ # Friendly handling for known hub errors: print the message + hint,
584
+ # never a raw stack trace.
585
+ from .hub import HubError
586
+ if isinstance(e, HubError):
587
+ sys.stderr.write(f"error: {e}\n")
588
+ if e.hint:
589
+ sys.stderr.write(f"\n{e.hint}\n")
590
+ return 1
591
+ # Network errors that slipped through unwrapped.
592
+ msg = str(e)
593
+ if any(s in msg for s in ("fetch failed", "ENOTFOUND", "ECONNREFUSED", "ECONNRESET", "urlopen error")):
594
+ sys.stderr.write(f"error: network error talking to GitHub: {msg}\n")
595
+ sys.stderr.write("\nThis is usually transient. Re-run the command, or run against a local checkout:\n")
596
+ sys.stderr.write(' agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"\n')
597
+ return 1
598
+ # Everything else: surface the message without dumping a traceback.
599
+ sys.stderr.write(f"error: {msg}\n")
600
+ return 1
501
601
 
502
602
 
503
603
  if __name__ == "__main__":
@@ -27,7 +27,7 @@ import time
27
27
  import urllib.error
28
28
  import urllib.request
29
29
  from pathlib import Path
30
- from typing import Callable, Dict, List, Optional
30
+ from typing import Any, Callable, Dict, List, Optional
31
31
 
32
32
  HUB_REPO = "jeffkit/agentproc"
33
33
  HUB_REF = "main"
@@ -38,6 +38,38 @@ GITHUB_TREES = "https://api.github.com/repos/{repo}/git/trees/{ref}?recursive=1"
38
38
  GITHUB_RAW = "https://raw.githubusercontent.com/{repo}/{ref}/{path}"
39
39
 
40
40
 
41
+ class HubError(RuntimeError):
42
+ """Hub fetch failure with a human-readable remediation hint.
43
+
44
+ The CLI prints `message` + `hint` to stderr instead of dumping a stack
45
+ trace. `status` is the HTTP status (0 for network errors).
46
+
47
+ Inherits from RuntimeError so existing `except RuntimeError` callers
48
+ continue to work.
49
+ """
50
+
51
+ def __init__(self, message: str, *, hint: str = "", status: int = 0) -> None:
52
+ super().__init__(message)
53
+ self.hint = hint
54
+ self.status = status
55
+
56
+
57
+ def _auth_headers(*, json: bool = False) -> Dict[str, str]:
58
+ """Build HTTP headers, adding Authorization if a token is in env.
59
+
60
+ GITHUB_TOKEN (the env var GitHub Actions injects) or GH_TOKEN (what
61
+ the `gh` CLI sets) raises GitHub's anonymous rate limit from ~60/hr
62
+ to ~5000/hr.
63
+ """
64
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or ""
65
+ h: Dict[str, str] = {"User-Agent": "agentproc-cli"}
66
+ if json:
67
+ h["Accept"] = "application/vnd.github+json"
68
+ if token:
69
+ h["Authorization"] = f"Bearer {token}"
70
+ return h
71
+
72
+
41
73
  def _cache_root() -> Path:
42
74
  """Root cache directory: ~/.agentproc/cache/hub/"""
43
75
  return Path.home() / ".agentproc" / "cache" / "hub"
@@ -73,19 +105,95 @@ def _write_cache_meta(name: str) -> None:
73
105
 
74
106
 
75
107
  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"))
108
+ """GET a URL, return parsed JSON. Raises HubError on failure."""
109
+ req = urllib.request.Request(url, headers=_auth_headers(json=True))
110
+ try:
111
+ with urllib.request.urlopen(req, timeout=timeout) as r:
112
+ return json.loads(r.read().decode("utf-8"))
113
+ except urllib.error.HTTPError as e:
114
+ body = ""
115
+ try:
116
+ body = e.read().decode("utf-8", errors="replace")
117
+ except Exception:
118
+ pass
119
+ raise _http_error_to_hub(e.code, body[:200], url, is_json=True) from e
120
+ except urllib.error.URLError as e:
121
+ # Network/DNS/connection-level errors (no HTTP status).
122
+ raise HubError(
123
+ "could not reach GitHub while fetching hub profile",
124
+ status=0,
125
+ hint=(
126
+ "This is usually a transient network issue. Try:\n"
127
+ " 1. Re-run the command (often succeeds on retry).\n"
128
+ " 2. If your network requires a proxy, set HTTPS_PROXY.\n"
129
+ " 3. To avoid the network entirely, run against a local checkout:\n"
130
+ " agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\""
131
+ ),
132
+ ) from e
83
133
 
84
134
 
85
135
  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")
136
+ req = urllib.request.Request(url, headers=_auth_headers(json=False))
137
+ try:
138
+ with urllib.request.urlopen(req, timeout=timeout) as r:
139
+ return r.read().decode("utf-8")
140
+ except urllib.error.HTTPError as e:
141
+ body = ""
142
+ try:
143
+ body = e.read().decode("utf-8", errors="replace")
144
+ except Exception:
145
+ pass
146
+ raise _http_error_to_hub(e.code, body[:200], url, is_json=False) from e
147
+ except urllib.error.URLError as e:
148
+ raise HubError(
149
+ "could not reach GitHub while fetching hub profile",
150
+ status=0,
151
+ hint=(
152
+ "This is usually a transient network issue. Try:\n"
153
+ " 1. Re-run the command (often succeeds on retry).\n"
154
+ " 2. If your network requires a proxy, set HTTPS_PROXY.\n"
155
+ " 3. To avoid the network entirely, run against a local checkout:\n"
156
+ " agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\""
157
+ ),
158
+ ) from e
159
+
160
+
161
+ def _http_error_to_hub(status: int, body: str, url: str, *, is_json: bool) -> HubError:
162
+ """Translate a GitHub HTTP error into a HubError with a remediation hint."""
163
+ authed = bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
164
+ if status in (403, 429):
165
+ if authed:
166
+ hint = (
167
+ "Your GITHUB_TOKEN is set but still rate-limited. Wait a few minutes and retry,\n"
168
+ "or run against a local checkout instead:\n"
169
+ " agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\"\n"
170
+ "\n"
171
+ "Not sure the profile name is right? Check with: agentproc hub list"
172
+ )
173
+ else:
174
+ hint = (
175
+ "GitHub limits anonymous hub fetches to ~60/hour. To raise this to 5,000/hour:\n"
176
+ " export GITHUB_TOKEN=$(gh auth token) # if you have the GitHub CLI\n"
177
+ " # or set GITHUB_TOKEN to any personal access token\n"
178
+ "\n"
179
+ "To skip the network entirely, run against a local checkout:\n"
180
+ " git clone https://github.com/jeffkit/agentproc && cd agentproc\n"
181
+ " agentproc --profile ./hub/<name>/profile.yaml --prompt \"hi\"\n"
182
+ "\n"
183
+ "Not sure the profile name is right? Check with: agentproc hub list"
184
+ )
185
+ return HubError(f"GitHub rate-limited the hub fetch (HTTP {status})", status=status, hint=hint)
186
+ if status == 404:
187
+ return HubError(
188
+ "profile not found on GitHub (HTTP 404)",
189
+ status=404,
190
+ hint="Check the profile name with `agentproc hub list`. (Typos are case-sensitive.)",
191
+ )
192
+ return HubError(
193
+ f"GitHub returned HTTP {status} for hub fetch",
194
+ status=status,
195
+ hint=body or "No additional detail from GitHub.",
196
+ )
89
197
 
90
198
 
91
199
  def _list_remote_files(subpath: str) -> List[Dict[str, str]]:
@@ -157,6 +265,85 @@ def _list_remote_profile_files(name: str) -> List[Dict[str, str]]:
157
265
  return out
158
266
 
159
267
 
268
+ def _list_profile_names() -> List[str]:
269
+ """List top-level profile names (directories directly under hub/).
270
+
271
+ Cheap: uses the same tree fetch as everything else, so this is one
272
+ network call at most (cached in-memory by urllib).
273
+ """
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 []
278
+ names: List[str] = []
279
+ seen = set()
280
+ for entry in data["tree"]:
281
+ if not isinstance(entry, dict):
282
+ continue
283
+ p = str(entry.get("path", ""))
284
+ if not p.startswith("hub/"):
285
+ continue
286
+ seg = p[len("hub/"):].split("/")[0]
287
+ # Directories prefixed with `_` (e.g. `_shared`) hold bridge
288
+ # utilities, not profiles — exclude from listings and suggestions.
289
+ if seg and not seg.startswith("_") and seg not in seen:
290
+ seen.add(seg)
291
+ names.append(seg)
292
+ return sorted(names)
293
+
294
+
295
+ def _suggest_close_name(input_name: str, candidates: List[str]) -> str:
296
+ """Lightweight "did you mean" hint.
297
+
298
+ Two paths to a match:
299
+ 1. Prefix match — `claude` matches `claude-code`, `echo` matches
300
+ `echo-agent`. Common typo pattern (user forgot a suffix).
301
+ Only accepts an unambiguous prefix.
302
+ 2. Edit distance with length-scaled threshold (≤6 → 1 edit,
303
+ 7-12 → 2, >12 → 3). Catches transpositions and small typos.
304
+ """
305
+ if not input_name or not candidates:
306
+ return ""
307
+ n = input_name.lower()
308
+ # Path 1: unique prefix match.
309
+ prefix_matches = [c for c in candidates if c.lower().startswith(n)]
310
+ if len(prefix_matches) == 1:
311
+ return prefix_matches[0]
312
+ # Path 2: edit distance.
313
+ threshold = 1 if len(input_name) <= 6 else (2 if len(input_name) <= 12 else 3)
314
+ best = ""
315
+ best_dist = float("inf")
316
+ for c in candidates:
317
+ d = _edit_distance(n, c.lower())
318
+ if d < best_dist:
319
+ best_dist = d
320
+ best = c
321
+ if best and best_dist <= threshold:
322
+ return best
323
+ return ""
324
+
325
+
326
+ def _edit_distance(a: str, b: str) -> int:
327
+ m, n = len(a), len(b)
328
+ if m == 0:
329
+ return n
330
+ if n == 0:
331
+ return m
332
+ prev = list(range(n + 1))
333
+ curr = [0] * (n + 1)
334
+ for i in range(1, m + 1):
335
+ curr[0] = i
336
+ for j in range(1, n + 1):
337
+ cost = 0 if a[i - 1] == b[j - 1] else 1
338
+ curr[j] = min(
339
+ prev[j] + 1, # deletion
340
+ curr[j - 1] + 1, # insertion
341
+ prev[j - 1] + cost # substitution
342
+ )
343
+ prev, curr = curr, prev
344
+ return prev[n]
345
+
346
+
160
347
  def _download_file(remote_path: str, local_path: Path) -> None:
161
348
  """Download a single file from raw.githubusercontent.com."""
162
349
  url = GITHUB_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=remote_path)
@@ -196,7 +383,27 @@ def fetch_profile(
196
383
 
197
384
  entries = _list_remote_profile_files(name)
198
385
  if not entries:
199
- raise RuntimeError(f"profile '{name}' not found in hub")
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.
389
+ known: List[str] = []
390
+ try:
391
+ known = _list_profile_names()
392
+ except Exception:
393
+ pass
394
+ suggestion = _suggest_close_name(name, known)
395
+ lines = []
396
+ if suggestion:
397
+ lines.append(f"Did you mean `{suggestion}`?")
398
+ lines.append("")
399
+ lines.append("Available profiles:")
400
+ for k in known:
401
+ lines.append(f" - {k}")
402
+ raise HubError(
403
+ f"profile '{name}' not found in hub",
404
+ status=404,
405
+ hint="\n".join(lines),
406
+ )
200
407
 
201
408
  # Clear cache, then re-download every file in the profile directory.
202
409
  if cached.exists():
@@ -227,6 +434,10 @@ def list_profiles(
227
434
  if entry["type"] != "dir":
228
435
  continue
229
436
  name = entry["name"]
437
+ # Skip utility directories like `_shared/` — they hold shared bridge
438
+ # helpers, not a runnable profile (no profile.yaml).
439
+ if name.startswith("_"):
440
+ continue
230
441
  # Read profile.yaml from raw URL to get metadata.
231
442
  try:
232
443
  yaml_url = GITHUB_RAW.format(
@@ -1,7 +1,8 @@
1
1
  """AgentProc runner — the canonical bridge-side engine.
2
2
 
3
3
  This module is the canonical implementation of the AgentProc bridge-side
4
- contract (spec/protocol.md). The CLI (cli.py) is a thin wrapper around it.
4
+ contract (spec/protocol.md, wire protocol 0.1). The CLI (cli.py) is a thin
5
+ wrapper around it.
5
6
 
6
7
  Responsibilities:
7
8
  - Parse and validate a profile dict
@@ -91,12 +92,14 @@ class RunOptions:
91
92
  streaming: Optional[bool] = None
92
93
  extra_env: Dict[str, str] = field(default_factory=dict)
93
94
  cwd: Optional[str] = None
95
+ profile_dir: Optional[str] = None
94
96
  timeout_secs: Optional[int] = None
95
97
  on_partial: Optional[Callable[[str], None]] = None
96
98
  on_session: Optional[Callable[[str], None]] = None
97
99
  on_error: Optional[Callable[[str], None]] = None
98
100
  on_protocol_line: Optional[Callable[[str], None]] = None
99
101
  on_stderr: Optional[Callable[[str], None]] = None
102
+ forward_stdin: Optional[bool] = None
100
103
 
101
104
 
102
105
  # ---------------------------------------------------------------------------
@@ -114,24 +117,52 @@ def normalize_profile(raw: Dict[str, Any]) -> Dict[str, Any]:
114
117
  if not isinstance(command, str) or not command.strip():
115
118
  raise ValueError("profile.command must be a non-empty string")
116
119
 
117
- argv = command.strip().split()
118
- if not argv:
119
- raise ValueError("profile.command produced empty argv")
120
-
121
- args_value = src.get("args", [])
120
+ has_args_field = "args" in src and src["args"] is not None
121
+ args_value = src.get("args") or []
122
122
  if not isinstance(args_value, list):
123
123
  raise ValueError("profile.args must be a list")
124
124
 
125
+ # Per spec: `command` is argv[0]; `args` is argv[1..]. Two mutually
126
+ # exclusive forms:
127
+ # (a) `args` absent + command has whitespace → split command into argv
128
+ # (the legacy shorthand: `command: python3 ./bridge.py`)
129
+ # (b) `args` present (even empty `[]`) → command is a single token,
130
+ # never split. This is the escape hatch for paths with whitespace:
131
+ # command: "/path with spaces/my agent"
132
+ # args: []
133
+ # `args: []` (explicit empty list) is DISTINCT from "args absent": the
134
+ # explicit form means "do not split command"; the absent form falls back
135
+ # to the whitespace-splitting shorthand.
136
+ if has_args_field:
137
+ argv = [command.strip()]
138
+ else:
139
+ argv = command.strip().split()
140
+ if not argv or not argv[0]:
141
+ raise ValueError("profile.command produced empty argv")
142
+
125
143
  cwd_value = src.get("cwd")
126
144
  env_value = src.get("env") or {}
127
145
  if not isinstance(env_value, dict):
128
146
  raise ValueError("profile.env must be a dict")
129
147
 
148
+ # env_allowlist (optional): when present, ${VAR} references in the env
149
+ # block whose name is NOT in the list expand to empty + a stderr warning.
150
+ # Absent ⇒ current behaviour (expand against the full bridge environment).
151
+ # Opt-in: existing profiles keep working unchanged.
152
+ allowlist_raw = src.get("env_allowlist")
153
+ if allowlist_raw is None:
154
+ env_allowlist: Optional[set] = None
155
+ elif isinstance(allowlist_raw, list):
156
+ env_allowlist = {str(x) for x in allowlist_raw}
157
+ else:
158
+ raise ValueError("profile.env_allowlist must be a list")
159
+
130
160
  return {
131
161
  "argv": argv,
132
162
  "args": [str(a) for a in args_value],
133
163
  "cwd": expand_path(str(cwd_value)) if cwd_value else None,
134
164
  "env": env_value,
165
+ "env_allowlist": env_allowlist,
135
166
  "stdin": "message" if src.get("stdin") == "message" else "none",
136
167
  "timeout_secs": (
137
168
  int(src["timeout_secs"]) if _is_int_like(src.get("timeout_secs"))
@@ -173,15 +204,127 @@ def substitute(value: str, ctx: Dict[str, str]) -> str:
173
204
  .replace("{{MESSAGE}}", ctx.get("message", ""))
174
205
  .replace("{{SESSION_ID}}", ctx.get("session_id", ""))
175
206
  .replace("{{SESSION_NAME}}", ctx.get("session_name", ""))
207
+ .replace("{{PROFILE_DIR}}", ctx.get("profile_dir", ""))
176
208
  )
177
209
 
178
210
 
179
- def expand_env_ref(value: str, env: Dict[str, str]) -> str:
180
- return re.sub(
181
- r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}",
182
- lambda m: env.get(m.group(1), ""),
183
- str(value),
184
- )
211
+ def expand_env_ref(
212
+ value: str,
213
+ env: Dict[str, str],
214
+ allowlist: Optional[set] = None,
215
+ on_blocked: Optional[Callable[[str], None]] = None,
216
+ ) -> str:
217
+ """Expand ${VAR} references against ``env``.
218
+
219
+ When ``allowlist`` is a set of variable names, references to names NOT in
220
+ the set expand to empty string and ``on_blocked`` (if given) is called
221
+ with each blocked name. When ``allowlist`` is None, all references expand
222
+ normally (the default, pre-allowlist behaviour).
223
+ """
224
+ def repl(m: "re.Match[str]") -> str:
225
+ name = m.group(1)
226
+ if allowlist is not None and name not in allowlist:
227
+ if on_blocked:
228
+ on_blocked(name)
229
+ return ""
230
+ return env.get(name, "")
231
+ return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", repl, str(value))
232
+
233
+
234
+ def _diagnose_spawn_error(
235
+ err: BaseException,
236
+ *,
237
+ argv: List[str],
238
+ cwd: Optional[str],
239
+ env: Dict[str, str],
240
+ ) -> str:
241
+ """Produce a human-friendly hint for a spawn FileNotFoundError / ENOENT.
242
+
243
+ Subprocess raises FileNotFoundError when either the command isn't on
244
+ PATH or the cwd doesn't exist — Python folds both into the same
245
+ exception attributed to argv[0], which is misleading. Disambiguate.
246
+ """
247
+ # (a) cwd doesn't exist or isn't a directory
248
+ if cwd:
249
+ p = Path(cwd)
250
+ try:
251
+ if not p.is_dir():
252
+ return f"profile.cwd is not a directory: {cwd}"
253
+ except PermissionError:
254
+ return f"profile.cwd is not accessible (permission denied): {cwd}"
255
+ except OSError:
256
+ return f"profile.cwd does not exist: {cwd}. Pass --cwd <path> to point at a real directory."
257
+
258
+ # (b) the command (argv[0]) is not on PATH (bare name, no slash)
259
+ cmd = argv[0] if argv else ""
260
+ is_pathed = "/" in cmd or "\\" in cmd
261
+ if not is_pathed and cmd:
262
+ from shutil import which
263
+ if not which(cmd):
264
+ return (
265
+ f"'{cmd}' not found on PATH. Install it, or if it's installed, "
266
+ "make sure PATH is set correctly when the bridge spawns the agent."
267
+ )
268
+
269
+ # (c) argv[0] looks like a path — check whether the file itself exists
270
+ if is_pathed and cmd:
271
+ if not Path(cmd).exists():
272
+ return f"command path does not exist or is not executable: {cmd}"
273
+
274
+ # (d) Command exists; suspect an argv file argument (e.g. python3 ./bridge.py)
275
+ for a in argv[1:]:
276
+ if a.startswith("-"):
277
+ continue
278
+ if "/" in a or "\\" in a:
279
+ resolved = a if Path(a).is_absolute() else (
280
+ str(Path(cwd) / a) if cwd else str(Path(a).resolve())
281
+ )
282
+ if not Path(resolved).exists():
283
+ return (
284
+ f"argument file not found: {a} (resolved to {resolved}). "
285
+ "The profile likely needs --cwd or the bundled script path is wrong."
286
+ )
287
+
288
+ return ""
289
+
290
+
291
+ def _diagnose_stderr_failure(stderr_text: str) -> str:
292
+ """Best-effort pattern check against the agent's accumulated stderr.
293
+
294
+ Catches "bridge file not found" failures that the wrapped interpreter
295
+ writes to its own stderr before exiting non-zero. Returns a friendly
296
+ hint, or '' if nothing recognizable.
297
+ """
298
+ if not stderr_text:
299
+ return ""
300
+ lower = stderr_text.lower()
301
+
302
+ # python3: "can't open file '/path/x.py': [Errno 2] No such file or directory"
303
+ m = re.search(r"(?:can'?t|cannot) open file '([^']+)': \[Errno 2\] No such file or directory", stderr_text)
304
+ if m:
305
+ return (
306
+ f"agent script not found: {m.group(1)}. Check the profile's command "
307
+ "path (likely a {{PROFILE_DIR}} issue or a typo)."
308
+ )
309
+
310
+ # node: "Error: Cannot find module '/path/x.js'"
311
+ m = re.search(r"Cannot find module '([^']+)'", stderr_text)
312
+ if m:
313
+ return (
314
+ f"agent script not found: {m.group(1)}. Check the profile's command "
315
+ "path (likely a {{PROFILE_DIR}} issue or a typo)."
316
+ )
317
+
318
+ # bash: "bash: line N: ./x.sh: No such file or directory"
319
+ m = re.search(r"(?:^|\n)[^:]+: line \d+: ([^:]+): No such file or directory", stderr_text)
320
+ if m:
321
+ return f"agent script not found: {m.group(1)}. Check the profile's command path."
322
+
323
+ # Generic errno 2 / ENOENT sentinel.
324
+ if re.search(r"errno 2|enoent|no such file or directory", lower):
325
+ return "agent reported a missing file. Check the profile's command and cwd."
326
+
327
+ return ""
185
328
 
186
329
 
187
330
  # ---------------------------------------------------------------------------
@@ -194,9 +337,13 @@ def decode_json_value(raw: str) -> str:
194
337
  return ""
195
338
  try:
196
339
  v = json.loads(text)
197
- return v if isinstance(v, str) else str(v)
198
340
  except json.JSONDecodeError:
199
341
  return text
342
+ # Only JSON strings are meaningful payloads — a sentinel's value is text
343
+ # for the user. Non-string JSON (number/bool/null/array/object) means the
344
+ # agent misused the API; fall back to the raw text so the result is
345
+ # language-independent (str(True) != String(true) across runtimes).
346
+ return v if isinstance(v, str) else text
200
347
 
201
348
 
202
349
  def classify_line(line: str) -> Dict[str, str]:
@@ -224,20 +371,39 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
224
371
  options.timeout_secs if options.timeout_secs is not None else profile["timeout_secs"]
225
372
  )
226
373
  cwd = options.cwd or profile["cwd"]
374
+ # Resolve relative cwd against the profile's own directory (if known),
375
+ # so profiles written as `cwd: .` work no matter where the user invokes
376
+ # from. Absolute paths and ~-prefixed paths are already absolute.
377
+ if cwd and not Path(cwd).is_absolute() and options.profile_dir:
378
+ cwd = str(Path(options.profile_dir) / cwd)
227
379
 
228
380
  subst_ctx = {
229
381
  "message": options.message,
230
382
  "session_id": options.session_id,
231
383
  "session_name": options.session_name,
384
+ "profile_dir": options.profile_dir or "",
232
385
  }
233
386
 
234
- argv = list(profile["argv"])
387
+ # Substitute placeholders in argv (command) too, not just args — so
388
+ # `command: python3 {{PROFILE_DIR}}/bridge.py` resolves correctly.
389
+ argv = [substitute(a, subst_ctx) for a in profile["argv"]]
235
390
  for a in profile["args"]:
236
391
  argv.append(substitute(a, subst_ctx))
237
392
 
238
393
  env = dict(os.environ)
394
+ allowlist = profile["env_allowlist"]
239
395
  for k, v in profile["env"].items():
240
- env[k] = expand_env_ref(substitute(str(v), subst_ctx), os.environ)
396
+ env[k] = expand_env_ref(
397
+ substitute(str(v), subst_ctx),
398
+ os.environ,
399
+ allowlist=allowlist,
400
+ on_blocked=(
401
+ lambda name: options.on_stderr(
402
+ f"[agentproc runner] env_allowlist blocked ${{{name}}} "
403
+ f"(not in allowlist); expanded to empty"
404
+ ) if options.on_stderr else None
405
+ ),
406
+ )
241
407
  for k, v in options.extra_env.items():
242
408
  env[k] = str(v)
243
409
 
@@ -248,10 +414,30 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
248
414
  env["AGENT_STREAMING"] = "1" if streaming else "0"
249
415
  env["AGENT_PROTOCOL_VERSION"] = PROTOCOL_VERSION
250
416
 
251
- stdin_payload = options.message if profile["stdin"] == "message" else None
417
+ # forward_stdin overrides profile.stdin: when True, always write the
418
+ # message to stdin (used by the CLI's --stdin flag). When None/False,
419
+ # fall back to the profile's stdin setting.
420
+ use_stdin = profile["stdin"] == "message" or bool(options.forward_stdin)
421
+ stdin_payload = options.message if use_stdin else None
252
422
 
253
423
  result = RunResult()
254
424
  body_lines: List[str] = []
425
+ # Two views on stderr:
426
+ # - stderr_window: bounded sliding window (8 KB) for the on_stderr UI
427
+ # callback, so a noisy agent cannot exhaust memory.
428
+ # - stderr_full: unbounded capture used only for post-mortem pattern
429
+ # diagnosis. Without the full text, a chatty agent can push the real
430
+ # error out of the window and the friendly hint goes missing.
431
+ stderr_window: List[str] = []
432
+ stderr_full: List[str] = []
433
+ STDERR_CAP = 8192
434
+
435
+ def _append_stderr(text: str) -> None:
436
+ stderr_full.append(text)
437
+ stderr_window.append(text)
438
+ total = sum(len(s) for s in stderr_window)
439
+ while total > STDERR_CAP and len(stderr_window) > 1:
440
+ total -= len(stderr_window.pop(0))
255
441
 
256
442
  try:
257
443
  proc = subprocess.Popen(
@@ -265,13 +451,24 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
265
451
  bufsize=1,
266
452
  )
267
453
  except FileNotFoundError as e:
454
+ tip = _diagnose_spawn_error(e, argv=argv, cwd=cwd, env=env)
268
455
  if options.on_stderr:
269
456
  options.on_stderr(f"[agentproc runner] spawn error: {e}")
457
+ if tip:
458
+ options.on_stderr(f"[agentproc runner] hint: {tip}")
459
+ if options.on_error:
460
+ options.on_error(f"failed to start agent: {tip or str(e)}")
461
+ if not result.error:
462
+ result.error = tip or str(e)
270
463
  result.exit_code = EXIT_ERROR
271
464
  return result
272
465
  except PermissionError as e:
273
466
  if options.on_stderr:
274
467
  options.on_stderr(f"[agentproc runner] spawn error: {e}")
468
+ if options.on_error:
469
+ options.on_error(f"failed to start agent: {e}")
470
+ if not result.error:
471
+ result.error = str(e)
275
472
  result.exit_code = EXIT_ERROR
276
473
  return result
277
474
 
@@ -285,6 +482,7 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
285
482
  def _drain_stderr() -> None:
286
483
  assert proc.stderr is not None
287
484
  for line in proc.stderr:
485
+ _append_stderr(line)
288
486
  line = line.rstrip("\r\n")
289
487
  if options.on_stderr:
290
488
  options.on_stderr(line)
@@ -337,15 +535,18 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
337
535
  except subprocess.TimeoutExpired:
338
536
  if time.monotonic() >= deadline:
339
537
  timed_out = True
538
+ # terminate() is SIGTERM on POSIX, TerminateProcess on
539
+ # Windows — both are the "polite shutdown" the spec's
540
+ # SIGTERM→SIGKILL contract refers to.
340
541
  try:
341
- proc.send_signal(signal.SIGTERM)
542
+ proc.terminate()
342
543
  except (ProcessLookupError, PermissionError):
343
544
  pass
344
545
  try:
345
546
  proc.wait(timeout=profile["kill_grace_secs"])
346
547
  except subprocess.TimeoutExpired:
347
548
  try:
348
- proc.send_signal(signal.SIGKILL)
549
+ proc.kill()
349
550
  except (ProcessLookupError, PermissionError):
350
551
  pass
351
552
  try:
@@ -356,20 +557,48 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
356
557
  else:
357
558
  exit_code = proc.wait()
358
559
  except KeyboardInterrupt:
359
- try:
360
- proc.send_signal(signal.SIGINT)
361
- except (ProcessLookupError, PermissionError):
362
- pass
560
+ # SIGINT only exists on POSIX. On Windows we fall back to terminate().
561
+ if hasattr(signal, "SIGINT"):
562
+ try:
563
+ proc.send_signal(signal.SIGINT)
564
+ except (ProcessLookupError, PermissionError):
565
+ pass
566
+ else:
567
+ try:
568
+ proc.terminate()
569
+ except (ProcessLookupError, PermissionError):
570
+ pass
363
571
  exit_code = proc.wait()
364
572
 
365
573
  stdout_thread.join(timeout=5)
366
- stderr_thread.join(timeout=2)
574
+ # Drain stderr fully before running post-mortem diagnosis. The diagnosis
575
+ # reads the full capture, so we need the drain thread to have caught up.
576
+ # Generous timeout since the process has already exited — at most we wait
577
+ # for the pipe buffer to flush.
578
+ stderr_thread.join(timeout=10)
579
+ if stderr_thread.is_alive():
580
+ # Very rare; indicates a pathological agent that keeps stderr open.
581
+ # The diagnosis may be incomplete, but we don't want to hang forever.
582
+ if options.on_stderr:
583
+ options.on_stderr("[agentproc runner] warning: stderr drain timed out; diagnosis may be incomplete")
367
584
 
368
585
  result.reply = "\n".join(body_lines)
369
586
  if len(result.reply) > profile["max_reply_chars"]:
370
587
  suffix = "\n\n…(truncated)" if profile["max_reply_chars"] == DEFAULT_MAX_REPLY_CHARS else ""
371
588
  result.reply = result.reply[: profile["max_reply_chars"]] + suffix
372
589
 
590
+ # If the agent exited non-zero with no AGENT_ERROR, peek at its stderr for
591
+ # common "command/file not found" patterns and surface a friendly hint.
592
+ # Uses the FULL stderr — a noisy agent can fill the 8 KB window with
593
+ # progress junk before the real error lands at the end.
594
+ if not timed_out and not result.error and exit_code != 0:
595
+ stderr_text = "".join(stderr_full)
596
+ hint = _diagnose_stderr_failure(stderr_text)
597
+ if hint:
598
+ result.error = hint
599
+ if options.on_error:
600
+ options.on_error(hint)
601
+
373
602
  if timed_out:
374
603
  result.timed_out = True
375
604
  result.exit_code = EXIT_TIMEOUT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentproc
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -9,5 +9,6 @@ src/agentproc.egg-info/dependency_links.txt
9
9
  src/agentproc.egg-info/entry_points.txt
10
10
  src/agentproc.egg-info/top_level.txt
11
11
  tests/test_agentproc.py
12
+ tests/test_conformance.py
12
13
  tests/test_hub.py
13
14
  tests/test_runner.py
@@ -0,0 +1,33 @@
1
+ """Cross-implementation conformance tests.
2
+
3
+ Drives the shared `spec/conformance/cases.json` fixture through the Python
4
+ runner's `classify_line` and asserts the result matches the expected
5
+ {kind, value}. The Node SDK runs the same fixture through its `classifyLine`
6
+ in `sdk/node/src/conformance.test.js` — together they guarantee the two
7
+ reference implementations classify stdout identically.
8
+
9
+ When you change the spec's line-recognition rules, add a case here first;
10
+ both SDKs will fail until they agree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ from agentproc.runner import classify_line
21
+
22
+ CASES_PATH = Path(__file__).resolve().parents[3] / "spec" / "conformance" / "cases.json"
23
+
24
+
25
+ def _load_cases():
26
+ data = json.loads(CASES_PATH.read_text(encoding="utf-8"))
27
+ return [pytest.param(c["line"], c["expect"], id=c["line"][:60]) for c in data["cases"]]
28
+
29
+
30
+ @pytest.mark.parametrize("line,expect", _load_cases())
31
+ def test_classify_line_conformance(line: str, expect: dict) -> None:
32
+ got = classify_line(line)
33
+ assert got == expect, f"line={line!r}: got {got}, expected {expect}"
@@ -32,6 +32,10 @@ from agentproc.hub import (
32
32
 
33
33
  FAKE_TREE = [
34
34
  {"path": "hub", "type": "tree"},
35
+ {"path": "hub/_shared", "type": "tree"},
36
+ {"path": "hub/_shared/stream_utils.py", "type": "blob"},
37
+ {"path": "hub/_shared/stream_utils.js", "type": "blob"},
38
+ {"path": "hub/_shared/README.md", "type": "blob"},
35
39
  {"path": "hub/echo-agent", "type": "tree"},
36
40
  {"path": "hub/echo-agent/profile.yaml", "type": "blob"},
37
41
  {"path": "hub/echo-agent/bridge.py", "type": "blob"},
@@ -237,6 +241,15 @@ class TestListProfiles:
237
241
  assert p["name"].startswith("") # just a sanity check
238
242
  assert "/" not in p["name"]
239
243
 
244
+ def test_list_skips_underscore_utility_dirs(self, isolated_cache):
245
+ """`_shared/` holds bridge helpers, not a profile — must be excluded."""
246
+ with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
247
+ patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
248
+ profiles = hub_mod.list_profiles()
249
+ names = [p["name"] for p in profiles]
250
+ assert not any(n.startswith("_") for n in names), names
251
+ assert "_shared" not in names
252
+
240
253
 
241
254
  # ---------------------------------------------------------------------------
242
255
  # show_readme
File without changes