hubzoid 0.2.2__py3-none-any.whl

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.
Files changed (47) hide show
  1. hubzoid/__init__.py +10 -0
  2. hubzoid/__main__.py +12 -0
  3. hubzoid/_fs.py +56 -0
  4. hubzoid/cli.py +482 -0
  5. hubzoid/factory.py +135 -0
  6. hubzoid/factory_claude.py +292 -0
  7. hubzoid/frontmatter.py +63 -0
  8. hubzoid/loaders/__init__.py +0 -0
  9. hubzoid/loaders/agents.py +129 -0
  10. hubzoid/loaders/knowledge.py +50 -0
  11. hubzoid/loaders/mcp.py +107 -0
  12. hubzoid/loaders/skills.py +77 -0
  13. hubzoid/loaders/tools_local.py +57 -0
  14. hubzoid/memory.py +21 -0
  15. hubzoid/model.py +60 -0
  16. hubzoid/runtime.py +104 -0
  17. hubzoid/server.py +182 -0
  18. hubzoid/settings.py +69 -0
  19. hubzoid/templates/starter/.gitignore +6 -0
  20. hubzoid/templates/starter/AGENTS.md +95 -0
  21. hubzoid/templates/starter/agents/builder/AGENTS.md +97 -0
  22. hubzoid/templates/starter/connectors/.mcp.json +3 -0
  23. hubzoid/templates/starter/knowledge/agents-md-format.md +59 -0
  24. hubzoid/templates/starter/knowledge/hub-folder-layout.md +65 -0
  25. hubzoid/templates/starter/knowledge/mcp-and-connectors.md +71 -0
  26. hubzoid/templates/starter/knowledge/three-agent-types.md +63 -0
  27. hubzoid/templates/starter/knowledge/welcome.md +42 -0
  28. hubzoid/templates/starter/knowledge/what-is-hubzoid.md +66 -0
  29. hubzoid/templates/starter/skills/build-first-agent/SKILL.md +74 -0
  30. hubzoid/templates/starter/skills/explain-skills/SKILL.md +73 -0
  31. hubzoid/templates/starter/skills/find-the-docs/SKILL.md +59 -0
  32. hubzoid/templates/starter/skills/inspect-this-hub/SKILL.md +70 -0
  33. hubzoid/templates/starter/tools_local/current_time.py +35 -0
  34. hubzoid/tools/__init__.py +21 -0
  35. hubzoid/tools/files.py +74 -0
  36. hubzoid/tools/knowledge.py +47 -0
  37. hubzoid/tools/memory.py +125 -0
  38. hubzoid/tools/render.py +38 -0
  39. hubzoid/tools/skills_tool.py +48 -0
  40. hubzoid/tools/web_http.py +91 -0
  41. hubzoid/webui.py +69 -0
  42. hubzoid-0.2.2.dist-info/METADATA +352 -0
  43. hubzoid-0.2.2.dist-info/RECORD +47 -0
  44. hubzoid-0.2.2.dist-info/WHEEL +5 -0
  45. hubzoid-0.2.2.dist-info/entry_points.txt +2 -0
  46. hubzoid-0.2.2.dist-info/licenses/LICENSE +21 -0
  47. hubzoid-0.2.2.dist-info/top_level.txt +1 -0
hubzoid/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """HubZoid — markdown-driven AI agent platform.
2
+
3
+ Drop AGENTS.md + skills/ + knowledge/ into a folder, get a working chat agent
4
+ with a polished web UI. See README.md.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ __version__ = "0.2.2"
9
+
10
+ from .factory import build_agent # noqa: E402,F401 (public re-export)
hubzoid/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Enables `python -m hubzoid …` as an alias for the installed `hubzoid` CLI.
2
+
3
+ Used by the no-install clone path:
4
+ git clone hubzoid && cd hubzoid && pip install -r requirements.txt
5
+ python -m hubzoid run demo-hub
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .cli import app
10
+
11
+ if __name__ == "__main__":
12
+ app()
hubzoid/_fs.py ADDED
@@ -0,0 +1,56 @@
1
+ """Case- and plural-insensitive folder resolution within a hub directory.
2
+
3
+ Hub authors may type `Skills/`, `skills/`, `skill/`, `Skill/` — all should
4
+ resolve to the same logical bucket. We pick the first match found on disk
5
+ (alphabetical by actual name) and warn if multiple are present.
6
+
7
+ The canonical names used internally are: agents, skills, knowledge, tools_local,
8
+ connectors, output. The mapping accepts the singular and any case variant.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from pathlib import Path
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ # Map a canonical bucket → set of acceptable directory names (lowercase).
18
+ # Singular and plural both accepted. Case-insensitive at match time.
19
+ _ALIASES: dict[str, tuple[str, ...]] = {
20
+ "agents": ("agents", "agent"),
21
+ "skills": ("skills", "skill"),
22
+ "knowledge": ("knowledge",),
23
+ "tools_local": ("tools_local", "tool_local", "tools", "local_tools"),
24
+ "connectors": ("connectors", "connector"),
25
+ "output": ("output", "outputs"),
26
+ }
27
+
28
+
29
+ def resolve_bucket(hub_dir: Path, bucket: str) -> Path | None:
30
+ """Return the actual on-disk path for the given canonical bucket, or None.
31
+
32
+ `bucket` must be one of the keys in `_ALIASES`. Folder names are matched
33
+ case-insensitively; singular/plural variants are accepted. If two valid
34
+ variants exist (e.g. both `Skills/` and `skill/`), the alphabetically-first
35
+ match is returned and a warning is logged.
36
+ """
37
+ if bucket not in _ALIASES:
38
+ raise ValueError(f"unknown bucket: {bucket!r}")
39
+ accepted = {a.lower() for a in _ALIASES[bucket]}
40
+
41
+ matches: list[Path] = []
42
+ if hub_dir.is_dir():
43
+ for child in sorted(hub_dir.iterdir(), key=lambda p: p.name.lower()):
44
+ if child.is_dir() and child.name.lower() in accepted:
45
+ matches.append(child)
46
+
47
+ if not matches:
48
+ return None
49
+ if len(matches) > 1:
50
+ names = ", ".join(m.name for m in matches)
51
+ log.warning(
52
+ "multiple folders match the %r bucket (%s) — using %r. "
53
+ "Consolidate to one to avoid surprises.",
54
+ bucket, names, matches[0].name,
55
+ )
56
+ return matches[0]
hubzoid/cli.py ADDED
@@ -0,0 +1,482 @@
1
+ """hubzoid CLI — typer app.
2
+
3
+ Commands:
4
+ hubzoid init [PATH] Scaffold a hub from the bundled template.
5
+ hubzoid run [PATH] Start FastAPI bridge + Open WebUI for a hub.
6
+ hubzoid doctor [PATH] Validate hub config and report issues.
7
+ hubzoid test [PATH] Send a hello prompt and assert non-empty response.
8
+ hubzoid version Print version.
9
+
10
+ Path defaults to `.` (the current directory) everywhere.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import importlib.resources as resources
15
+ import os
16
+ import shutil
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ import typer
24
+ from rich.console import Console
25
+
26
+ from . import __version__
27
+ from . import settings as settingslib
28
+
29
+ app = typer.Typer(
30
+ name="hubzoid",
31
+ add_completion=False,
32
+ no_args_is_help=True,
33
+ help="Drop a folder of markdown files, get a chat agent with a polished web UI.",
34
+ )
35
+ console = Console()
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # init
40
+ # ---------------------------------------------------------------------------
41
+ @app.command()
42
+ def init(
43
+ name: Path = typer.Argument(
44
+ Path("demo-hub"),
45
+ help="Name of the new hub folder. Created under the current directory. Default: demo-hub.",
46
+ ),
47
+ force: bool = typer.Option(False, "--force", help="Overwrite existing files in the hub folder."),
48
+ ) -> None:
49
+ """Scaffold a new hub. Also drops agents-repo wrapper files if the parent looks fresh.
50
+
51
+ First run in an empty directory:
52
+ $ hubzoid init devops-agent
53
+ → writes ./requirements.txt, ./.gitignore, ./README.md, ./devops-agent/...
54
+
55
+ Second run in the same directory:
56
+ $ hubzoid init irs-agent
57
+ → writes ./irs-agent/... only. Parent files are left alone.
58
+
59
+ The result is a Samarth-style multi-hub agents repo built one hub at a time.
60
+ """
61
+ # Resolve. If `name` is just a folder name, drop it under cwd. If it is
62
+ # `.`, init in cwd itself (legacy / "I am already in my hub dir" case).
63
+ if str(name) == ".":
64
+ hub_dir = Path.cwd().resolve()
65
+ is_in_place = True
66
+ else:
67
+ hub_dir = (Path.cwd() / name).resolve() if not name.is_absolute() else name.resolve()
68
+ is_in_place = False
69
+
70
+ template_root = _template_root()
71
+ if template_root is None:
72
+ console.print("[red]Bundled template not found in the installed package.[/red]")
73
+ raise typer.Exit(2)
74
+
75
+ parent = hub_dir.parent
76
+ # Check parent freshness BEFORE creating the hub folder, so the hub we are
77
+ # about to create does not itself disqualify the parent.
78
+ parent_is_fresh = (not is_in_place) and _parent_looks_fresh(parent, ignore=hub_dir.name)
79
+
80
+ hub_dir.mkdir(parents=True, exist_ok=True)
81
+
82
+ # 1. Scaffold the hub folder from the bundled template.
83
+ written: list[Path] = []
84
+ skipped: list[Path] = []
85
+ for src in template_root.rglob("*"):
86
+ if src.is_dir():
87
+ continue
88
+ rel = src.relative_to(template_root)
89
+ dst = hub_dir / rel
90
+ if dst.exists() and not force:
91
+ skipped.append(dst)
92
+ continue
93
+ dst.parent.mkdir(parents=True, exist_ok=True)
94
+ shutil.copy2(src, dst)
95
+ written.append(dst)
96
+
97
+ # 2. Write .env from a Python constant (not part of the template tree
98
+ # because .env is gitignored). Same skip rules as template files.
99
+ env_dst = hub_dir / ".env"
100
+ if env_dst.exists() and not force:
101
+ skipped.append(env_dst)
102
+ else:
103
+ env_dst.write_text(_STARTER_ENV)
104
+ written.append(env_dst)
105
+
106
+ # 3. If the parent looks fresh and we are scaffolding a sub-folder, drop
107
+ # the agents-repo wrapper files. Never overwrite existing ones, with or
108
+ # without --force (parent files are not the hub's concern).
109
+ parent_written: list[Path] = []
110
+ if parent_is_fresh:
111
+ version_str = _installed_version()
112
+ wrapper = _wrapper_files(parent, hub_dir.name, version_str)
113
+ for dst, content in wrapper.items():
114
+ if dst.exists():
115
+ continue
116
+ dst.write_text(content)
117
+ parent_written.append(dst)
118
+
119
+ # 3. Report.
120
+ console.print(f"[green]Initialized hub at[/green] {hub_dir}")
121
+ if written:
122
+ console.print(f" wrote {len(written)} hub files")
123
+ if skipped:
124
+ console.print(f" skipped {len(skipped)} existing files (use --force to overwrite)")
125
+ if parent_written:
126
+ console.print(f"\n[green]Bootstrapped agents-repo wrapper at[/green] {parent}")
127
+ for p in parent_written:
128
+ console.print(f" + {p.name}")
129
+
130
+ console.print("\nNext:")
131
+ console.print(f" 1. edit {hub_dir.name}/.env if you do not have `claude` CLI logged in")
132
+ console.print(f" 2. hubzoid run {hub_dir.name}")
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # run
137
+ # ---------------------------------------------------------------------------
138
+ @app.command()
139
+ def run(
140
+ hub: Path = typer.Argument(Path("."), help="Hub directory. Default: current dir."),
141
+ port: int = typer.Option(None, "--port", help="Open WebUI port. Default: 3080 (or PORT env)."),
142
+ bridge_port: int = typer.Option(None, "--bridge-port", help="FastAPI bridge port. Default: 8000 (or BRIDGE_PORT env)."),
143
+ no_ui: bool = typer.Option(False, "--no-ui", help="Skip Open WebUI; bridge only."),
144
+ ) -> None:
145
+ """Start the bridge (+ Open WebUI) for a hub."""
146
+ hub = hub.resolve()
147
+ if not hub.is_dir():
148
+ console.print(f"[red]Hub directory not found:[/red] {hub}")
149
+ # If the user is currently inside a hub folder, point that out.
150
+ cwd = Path.cwd()
151
+ if (cwd / "AGENTS.md").is_file():
152
+ console.print(
153
+ f"[yellow]Your current directory ({cwd}) looks like a hub. "
154
+ f"Try:[/yellow]\n python -m hubzoid run . (or just: hubzoid run .)"
155
+ )
156
+ else:
157
+ console.print(
158
+ "[yellow]Tip:[/yellow] paths are resolved against the current "
159
+ f"directory ({cwd}). Run from the repo root, or pass `.` from inside the hub folder."
160
+ )
161
+ raise typer.Exit(2)
162
+ if not (hub / "AGENTS.md").is_file():
163
+ console.print(f"[red]No AGENTS.md in {hub}. Run `hubzoid init` first.[/red]")
164
+ raise typer.Exit(2)
165
+
166
+ settings = settingslib.load(hub)
167
+ ui_port = port or settings.ui_port
168
+ br_port = bridge_port or settings.bridge_port
169
+
170
+ # 1. Start the bridge in a subprocess. We pass HUBZOID_HUB_DIR via env so
171
+ # `hubzoid.server.build_app` knows what to load.
172
+ bridge_env = os.environ.copy()
173
+ bridge_env["HUBZOID_HUB_DIR"] = str(hub)
174
+ bridge_cmd = [
175
+ sys.executable, "-m", "uvicorn",
176
+ "hubzoid.server:build_app", "--factory",
177
+ "--host", "127.0.0.1", "--port", str(br_port),
178
+ "--log-level", settings.log_level,
179
+ ]
180
+ console.print(f"[cyan]→ bridge[/cyan] http://127.0.0.1:{br_port} (hub: {hub.name})")
181
+ bridge_proc = subprocess.Popen(bridge_cmd, env=bridge_env)
182
+
183
+ # 2. Wait for the bridge to come up before starting Open WebUI.
184
+ if not _wait_for(f"http://127.0.0.1:{br_port}/healthz", timeout=60):
185
+ console.print("[red]bridge failed to come up[/red]")
186
+ bridge_proc.terminate()
187
+ raise typer.Exit(1)
188
+ console.print("[green]→ bridge[/green] ready")
189
+
190
+ ui_proc = None
191
+ if not no_ui:
192
+ try:
193
+ from . import webui
194
+ ui_proc = webui.start(
195
+ hub_dir=hub,
196
+ bridge_port=br_port,
197
+ ui_port=ui_port,
198
+ api_key=settings.first_api_key,
199
+ model_label=settings.model_label or _read_main_agent_name(hub),
200
+ webui_name=settings.webui_name,
201
+ )
202
+ log_path = getattr(ui_proc, "_log_path", None)
203
+ console.print(f"[cyan]→ webui [/cyan] http://127.0.0.1:{ui_port} (booting, first start takes 1-2 min while it downloads its embedding model)")
204
+ if log_path:
205
+ console.print(f" log: {log_path}")
206
+ except FileNotFoundError as exc:
207
+ console.print(f"[yellow]{exc}[/yellow]")
208
+ console.print("Bridge only. Curl http://127.0.0.1:" + str(br_port) + "/v1/chat/completions to chat.")
209
+
210
+ def _shutdown(signum, frame): # noqa: ARG001
211
+ console.print("\n[cyan]shutting down...[/cyan]")
212
+ for p in (ui_proc, bridge_proc):
213
+ if p is not None and p.poll() is None:
214
+ p.terminate()
215
+ sys.exit(0)
216
+
217
+ signal.signal(signal.SIGINT, _shutdown)
218
+ signal.signal(signal.SIGTERM, _shutdown)
219
+ # Block on the bridge process; its exit ends the CLI.
220
+ try:
221
+ bridge_proc.wait()
222
+ finally:
223
+ if ui_proc is not None and ui_proc.poll() is None:
224
+ ui_proc.terminate()
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # doctor
229
+ # ---------------------------------------------------------------------------
230
+ @app.command()
231
+ def doctor(
232
+ hub: Path = typer.Argument(Path("."), help="Hub directory. Default: current dir."),
233
+ ) -> None:
234
+ """Validate a hub: AGENTS.md, sub-agents, skills, knowledge, tools, .env."""
235
+ hub = hub.resolve()
236
+ problems: list[str] = []
237
+ notes: list[str] = []
238
+
239
+ if not hub.is_dir():
240
+ console.print(f"[red]Hub directory not found:[/red] {hub}")
241
+ raise typer.Exit(2)
242
+
243
+ if not (hub / "AGENTS.md").is_file():
244
+ problems.append("missing AGENTS.md at hub root")
245
+
246
+ env_path = hub / ".env"
247
+ if not env_path.is_file():
248
+ notes.append(f"no .env at {env_path} (run `hubzoid init` to scaffold one, or create it by hand)")
249
+
250
+ # Try to actually build the runtime — this is the most thorough check.
251
+ # Picks the backend based on MODEL (openai-agents by default,
252
+ # claude-local when MODEL=claude-local).
253
+ try:
254
+ from . import runtime as runtime_lib
255
+ rt = runtime_lib.build(hub)
256
+ notes.append(f"runtime built: {rt.name!r} via {type(rt).__name__}")
257
+ except Exception as exc: # noqa: BLE001
258
+ problems.append(f"runtime build failed: {type(exc).__name__}: {exc}")
259
+
260
+ for n in notes:
261
+ console.print(f"[green]✓[/green] {n}")
262
+ for p in problems:
263
+ console.print(f"[red]✗[/red] {p}")
264
+ if problems:
265
+ raise typer.Exit(1)
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # test
270
+ # ---------------------------------------------------------------------------
271
+ @app.command("test")
272
+ def test_hub(
273
+ hub: Path = typer.Argument(Path("."), help="Hub directory. Default: current dir."),
274
+ prompt: str = typer.Option("Reply with the single word: pong", "--prompt", help="Test prompt to send."),
275
+ ) -> None:
276
+ """Send one prompt to the hub's agent and print the response.
277
+
278
+ Runs in-process (no bridge / no UI). Backend is picked from MODEL in .env:
279
+ `claude-local` -> Claude Agent SDK; anything else -> OpenAI Agents SDK.
280
+ """
281
+ import asyncio
282
+
283
+ hub = hub.resolve()
284
+ settings = settingslib.load(hub)
285
+ if not settings.model:
286
+ console.print("[red]MODEL is not set in .env. Cannot run test.[/red]")
287
+ raise typer.Exit(2)
288
+
289
+ from . import runtime as runtime_lib
290
+
291
+ rt = runtime_lib.build(hub)
292
+ console.print(f"[cyan]→[/cyan] {prompt}")
293
+ text = asyncio.run(rt.run(prompt))
294
+ console.print(f"[green]←[/green] {text}")
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # version
299
+ # ---------------------------------------------------------------------------
300
+ @app.command()
301
+ def version() -> None:
302
+ """Print the installed hubzoid version."""
303
+ console.print(__version__)
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # helpers
308
+ # ---------------------------------------------------------------------------
309
+ _STARTER_ENV = """\
310
+ # demo-hub configuration. This file is git-ignored.
311
+ #
312
+ # The default below uses your installed `claude` CLI and Pro/Max subscription
313
+ # for inference. No API key needed. Requires `claude login` already done.
314
+ #
315
+ # To use a hosted provider instead, comment out MODEL=claude-local and
316
+ # uncomment one of the alternative stanzas. Set the matching API key.
317
+
318
+ MODEL=claude-local
319
+ # MODEL=claude-local/sonnet # pin Sonnet
320
+ # MODEL=claude-local/opus # pin Opus
321
+ # MODEL=claude-local/haiku # pin Haiku
322
+
323
+ # --- OpenRouter (one key, many models) -------------------------------------
324
+ # OPENROUTER_API_KEY=
325
+ # MODEL=openrouter/anthropic/claude-haiku-4.5
326
+
327
+ # --- OpenAI -----------------------------------------------------------------
328
+ # OPENAI_API_KEY=
329
+ # MODEL=openai/gpt-4o-mini
330
+
331
+ # --- Anthropic --------------------------------------------------------------
332
+ # ANTHROPIC_API_KEY=
333
+ # MODEL=anthropic/claude-haiku-4-5
334
+
335
+ # --- Bridge / UI knobs (all optional) --------------------------------------
336
+ WEBUI_NAME=Hubzoid Guide
337
+ # BRIDGE_API_KEYS=dev # comma-separated; first one is what Open WebUI sees
338
+ # MODEL_LABEL= # what /v1/models reports; blank = derived from AGENTS.md name
339
+ # PORT=3080 # Open WebUI port
340
+ # BRIDGE_PORT=8000 # FastAPI bridge port
341
+ # HTTP_ALLOWLIST= # comma-separated hostnames the http_get tool may visit
342
+ """
343
+
344
+
345
+ def _installed_version() -> str:
346
+ """Return the installed hubzoid version, or the source-tree version as a fallback."""
347
+ try:
348
+ from importlib.metadata import PackageNotFoundError, version as _ver
349
+ try:
350
+ return _ver("hubzoid")
351
+ except PackageNotFoundError:
352
+ pass
353
+ except ImportError:
354
+ pass
355
+ return __version__
356
+
357
+
358
+ def _parent_looks_fresh(parent: Path, *, ignore: str) -> bool:
359
+ """Heuristic: parent is empty enough to be a fresh agents-repo wrapper.
360
+
361
+ Empty parent → fresh. Parent that contains only dotfiles, a README, a
362
+ requirements.txt, a LICENSE, a `.venv`, or the hub folder we are about
363
+ to create → also fresh. Anything else (sibling hub folders, src/, etc.)
364
+ means this is an existing project; do not write parent files.
365
+ """
366
+ if not parent.exists():
367
+ return True
368
+ allowed = {"README.md", "requirements.txt", "LICENSE", "LICENSE.md", ".env"}
369
+ for entry in parent.iterdir():
370
+ if entry.name == ignore:
371
+ continue
372
+ if entry.name.startswith("."):
373
+ continue
374
+ if entry.name in allowed:
375
+ continue
376
+ return False
377
+ return True
378
+
379
+
380
+ def _wrapper_files(parent: Path, hub_name: str, version_str: str) -> dict[Path, str]:
381
+ """The agents-repo wrapper files to drop at the parent level on first init."""
382
+ requirements_txt = (
383
+ "# Hubzoid agents repo. One hub per sibling folder.\n"
384
+ "# Replace the pin below with your version. For private mirrors, swap to:\n"
385
+ "# git+ssh://git@github.com/<org>/<your-mirror>@v<version>#egg=hubzoid\n"
386
+ f"hubzoid=={version_str}\n"
387
+ )
388
+ gitignore = (
389
+ "# Hubzoid\n"
390
+ ".env\n"
391
+ "output/\n"
392
+ ".openwebui-data/\n"
393
+ "\n"
394
+ "# Python\n"
395
+ "__pycache__/\n"
396
+ "*.pyc\n"
397
+ ".venv/\n"
398
+ ".pytest_cache/\n"
399
+ "\n"
400
+ "# OS\n"
401
+ ".DS_Store\n"
402
+ )
403
+ readme = (
404
+ f"# {parent.name}\n"
405
+ "\n"
406
+ "Hubzoid agents repo. Each subfolder is a hub.\n"
407
+ "\n"
408
+ "## Run a hub\n"
409
+ "\n"
410
+ "```bash\n"
411
+ "python -m venv .venv && source .venv/bin/activate\n"
412
+ "pip install -r requirements.txt\n"
413
+ f"hubzoid run {hub_name}\n"
414
+ "```\n"
415
+ "\n"
416
+ "## Add another hub\n"
417
+ "\n"
418
+ "```bash\n"
419
+ "hubzoid init <hub-name>\n"
420
+ "```\n"
421
+ "\n"
422
+ "Each hub gets its own `.env`, its own port, and its own user database.\n"
423
+ "Agents are independent products.\n"
424
+ "\n"
425
+ "## Where the framework lives\n"
426
+ "\n"
427
+ "Installed from PyPI via `requirements.txt`. Framework source is at\n"
428
+ "[github.com/hubzoid/hubzoid](https://github.com/hubzoid/hubzoid).\n"
429
+ )
430
+ return {
431
+ parent / "requirements.txt": requirements_txt,
432
+ parent / ".gitignore": gitignore,
433
+ parent / "README.md": readme,
434
+ }
435
+
436
+
437
+ def _template_root() -> Path | None:
438
+ """Return the on-disk path of the bundled starter template, or None."""
439
+ try:
440
+ root = resources.files("hubzoid") / "templates" / "starter"
441
+ except (ModuleNotFoundError, FileNotFoundError):
442
+ return None
443
+ # `resources.files` returns a Traversable; we need a real Path. For files
444
+ # installed normally (not zipped), this just works.
445
+ p = Path(str(root))
446
+ return p if p.exists() else None
447
+
448
+
449
+ def _wait_for(url: str, timeout: float = 60.0) -> bool:
450
+ import httpx
451
+ deadline = time.time() + timeout
452
+ while time.time() < deadline:
453
+ try:
454
+ r = httpx.get(url, timeout=2.0)
455
+ if r.status_code < 500:
456
+ return True
457
+ except httpx.HTTPError:
458
+ pass
459
+ time.sleep(0.5)
460
+ return False
461
+
462
+
463
+ def _read_main_agent_name(hub: Path) -> str:
464
+ """Pull the `name:` from AGENTS.md frontmatter to use as the model label."""
465
+ from . import frontmatter as fm
466
+ try:
467
+ data, _ = fm.read(hub / "AGENTS.md")
468
+ name = data.get("name", "agent")
469
+ return _slugify(name)
470
+ except Exception: # noqa: BLE001
471
+ return "agent"
472
+
473
+
474
+ def _slugify(text: str) -> str:
475
+ out = "".join(c if c.isalnum() else "-" for c in str(text).strip().lower())
476
+ while "--" in out:
477
+ out = out.replace("--", "-")
478
+ return out.strip("-") or "agent"
479
+
480
+
481
+ if __name__ == "__main__":
482
+ app()
hubzoid/factory.py ADDED
@@ -0,0 +1,135 @@
1
+ """Top-level: build_agent(hub_dir) -> Agent.
2
+
3
+ Walks a hub folder, loads everything, and assembles an OpenAI Agents SDK
4
+ Agent with sub-agents (as handoffs), pre-shipped tools, hub-local tools,
5
+ skills/knowledge tools, and MCP servers.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from agents import Agent
14
+ from agents.tool import FunctionTool
15
+
16
+ from . import memory as memlib
17
+ from . import model as modellib
18
+ from . import settings as settingslib
19
+ from .loaders import agents as agents_loader
20
+ from .loaders import knowledge as knowledge_loader
21
+ from .loaders import mcp as mcp_loader
22
+ from .loaders import skills as skills_loader
23
+ from .loaders import tools_local as tools_local_loader
24
+ from .tools import make_all as make_builtin_tools
25
+
26
+ log = logging.getLogger("hubzoid")
27
+
28
+
29
+ @dataclass
30
+ class HubContext:
31
+ hub_dir: Path
32
+ output_dir: Path
33
+ session_id: str
34
+ settings: "settingslib.Settings"
35
+ skills: list = field(default_factory=list)
36
+ knowledge: list = field(default_factory=list)
37
+
38
+
39
+ def build_agent(hub_dir: Path) -> Agent:
40
+ """Build and return the main Agent for the hub at `hub_dir`.
41
+
42
+ Sub-agents are wired as handoffs. Tools are scoped per sub-agent based on
43
+ each one's `tools:` frontmatter whitelist. Missing tool names raise with a
44
+ list of valid names.
45
+ """
46
+ hub_dir = Path(hub_dir).resolve()
47
+ if not hub_dir.is_dir():
48
+ raise FileNotFoundError(f"hub directory not found: {hub_dir}")
49
+
50
+ settings = settingslib.load(hub_dir)
51
+ session_id = memlib.make_session_id()
52
+ output_dir = memlib.session_output_dir(hub_dir, session_id)
53
+
54
+ skills = skills_loader.load_all(hub_dir)
55
+ knowledge = knowledge_loader.load_all(hub_dir)
56
+ log.info(
57
+ "hub %s: %d skill(s), %d knowledge doc(s)",
58
+ hub_dir.name, len(skills), len(knowledge),
59
+ )
60
+
61
+ ctx = HubContext(
62
+ hub_dir=hub_dir,
63
+ output_dir=output_dir,
64
+ session_id=session_id,
65
+ settings=settings,
66
+ skills=skills,
67
+ knowledge=knowledge,
68
+ )
69
+
70
+ # Tool registry: pre-shipped (with closures over ctx) + hub-local.
71
+ builtin: dict[str, FunctionTool] = make_builtin_tools(ctx) # name -> tool
72
+ local: dict[str, FunctionTool] = tools_local_loader.load_all(hub_dir)
73
+ overlap = set(builtin) & set(local)
74
+ if overlap:
75
+ log.info("hub-local tools override built-ins: %s", sorted(overlap))
76
+ registry: dict[str, FunctionTool] = {**builtin, **local}
77
+
78
+ mcp_servers = mcp_loader.load_all(hub_dir)
79
+
80
+ # Sub-agents first; the main agent needs them as `handoffs=[...]`.
81
+ sub_specs = agents_loader.load_subagents(hub_dir)
82
+ handoffs: list[Agent] = [
83
+ _build_one(spec, registry=registry, default_model=settings.model)
84
+ for spec in sub_specs
85
+ ]
86
+ log.info("hub %s: %d sub-agent(s)", hub_dir.name, len(handoffs))
87
+
88
+ main_spec = agents_loader.load_main(hub_dir)
89
+ main_model_id = main_spec.spec.model or settings.model
90
+ if not main_model_id:
91
+ raise RuntimeError(
92
+ "no model configured. Set MODEL in <hub>/.env or `model:` in AGENTS.md frontmatter."
93
+ )
94
+ main_model = modellib.build(main_model_id)
95
+
96
+ # The main agent gets ALL tools (whitelist on the main agent is treated as full access).
97
+ main_tools = list(registry.values())
98
+
99
+ main = Agent(
100
+ name=main_spec.spec.name,
101
+ instructions=main_spec.instructions,
102
+ model=main_model,
103
+ tools=main_tools,
104
+ handoffs=handoffs,
105
+ mcp_servers=mcp_servers,
106
+ )
107
+ return main
108
+
109
+
110
+ def _build_one(loaded: agents_loader.LoadedAgent, *, registry: dict[str, FunctionTool], default_model: str | None) -> Agent:
111
+ model_id = loaded.spec.model or default_model
112
+ if not model_id:
113
+ raise RuntimeError(
114
+ f"{loaded.source_path}: no model. Set `model:` in frontmatter or MODEL in <hub>/.env."
115
+ )
116
+
117
+ tools: list[FunctionTool] = []
118
+ if loaded.spec.tools:
119
+ unknown = [t for t in loaded.spec.tools if t not in registry]
120
+ if unknown:
121
+ available = ", ".join(sorted(registry)) or "(none)"
122
+ raise RuntimeError(
123
+ f"{loaded.source_path}: tools reference unknown names: {unknown}. "
124
+ f"Available: {available}"
125
+ )
126
+ tools = [registry[t] for t in loaded.spec.tools]
127
+ # If no tools specified, sub-agent gets none (explicit-default-deny).
128
+
129
+ return Agent(
130
+ name=loaded.spec.name,
131
+ handoff_description=loaded.spec.description,
132
+ instructions=loaded.instructions,
133
+ model=modellib.build(model_id),
134
+ tools=tools,
135
+ )