xybern 2.0.0__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.
xybern/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ Xybern — identity & authorisation infrastructure for AI agents.
3
+
4
+ Install once; Xybern discovers every AI agent in your system, gives each a
5
+ cryptographic identity, and (when you turn enforcement on) authorises every
6
+ action before it executes.
7
+
8
+ Quick start::
9
+
10
+ from xybern import auto
11
+ auto.connect() # uses the key from `xybern login`, env, or config
12
+
13
+ # discovers frameworks + agents + tools, registers them to your workspace
14
+ # (each gets a DID), and instruments them in OBSERVE mode by default.
15
+
16
+ CLI::
17
+
18
+ xybern login # device-code browser flow (auto-picks your workspace key)
19
+ xybern agents # dry-run: show what would be discovered
20
+ xybern status
21
+ xybern enforce on # switch from observe to active authorisation
22
+ """
23
+
24
+ from . import auto
25
+ from .config import Config
26
+
27
+ __version__ = "2.0.0"
28
+
29
+ __all__ = ["auto", "Config", "__version__"]
xybern/auto.py ADDED
@@ -0,0 +1,167 @@
1
+ """The one-line entrypoint: discover, register (with identity), instrument.
2
+
3
+ from xybern import auto
4
+ auto.connect()
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, List, Optional
13
+
14
+ from . import discovery
15
+ from .client import XybernClient
16
+ from .config import Config
17
+
18
+ log = logging.getLogger("xybern")
19
+
20
+
21
+ @dataclass
22
+ class ConnectResult:
23
+ authenticated: bool
24
+ mode: str
25
+ frameworks: List[str] = field(default_factory=list)
26
+ agents: List[str] = field(default_factory=list)
27
+ instrumented: List[str] = field(default_factory=list)
28
+ workspace: Optional[str] = None
29
+
30
+ def summary(self) -> str:
31
+ lines = [
32
+ f"✓ Detected frameworks: {', '.join(self.frameworks) or 'none'}",
33
+ f"✓ Found {len(self.agents)} agent(s)/tool(s)",
34
+ ]
35
+ if self.authenticated:
36
+ lines.append(f"✓ Registered to workspace (each issued an identity)")
37
+ lines.append(f"✓ Mode: {self.mode.upper()}" +
38
+ (" — actions logged, nothing blocked yet" if self.mode == "observe" else " — authorising actions"))
39
+ else:
40
+ lines.append("⚠ Not connected — run `xybern login` (or set XYBERN_API_KEY) to register + govern")
41
+ return "\n".join(lines)
42
+
43
+
44
+ class _Registrar:
45
+ """Idempotently maps discovered agents → registered agent_ids (with identity)."""
46
+ def __init__(self, client: XybernClient, cfg: Config):
47
+ self.client = client
48
+ self.cfg = cfg
49
+ self._by_key: Dict[str, str] = {} # name|framework -> agent_id
50
+ self._loaded = False
51
+
52
+ def _key(self, name: str, framework: str) -> str:
53
+ return f"{framework}|{name}".lower()
54
+
55
+ def _load_existing(self):
56
+ if self._loaded or not self.cfg.authenticated:
57
+ return
58
+ self._loaded = True
59
+ try:
60
+ data = self.client.list_agents()
61
+ for a in (data.get("agents") or []):
62
+ self._by_key[self._key(a.get("name", ""), a.get("framework", ""))] = a.get("agent_id")
63
+ except Exception:
64
+ pass
65
+
66
+ def ensure(self, info: "discovery.AgentInfo") -> Optional[str]:
67
+ if not self.cfg.authenticated:
68
+ return None
69
+ self._load_existing()
70
+ k = self._key(info.name, info.framework)
71
+ if k in self._by_key:
72
+ return self._by_key[k]
73
+ try:
74
+ resp = self.client.register_agent(
75
+ name=info.name, framework=info.framework,
76
+ capabilities=info.capabilities or [],
77
+ description=f"Auto-discovered {info.kind} via Xybern SDK",
78
+ )
79
+ agent_id = (resp.get("agent") or {}).get("agent_id")
80
+ if agent_id:
81
+ self._by_key[k] = agent_id
82
+ return agent_id
83
+ except Exception as e:
84
+ log.debug("xybern: register failed for %s: %s", info.name, e)
85
+ return None
86
+
87
+
88
+ _STATE: Dict = {"connected": False}
89
+
90
+
91
+ def connect(api_key: Optional[str] = None, mode: Optional[str] = None,
92
+ frameworks: Optional[List[str]] = None, instrument: bool = True,
93
+ **overrides) -> ConnectResult:
94
+ """Discover agents, register them (with cryptographic identity), and instrument
95
+ the detected frameworks. OBSERVE mode by default — nothing is ever blocked until
96
+ you switch to enforce (`xybern enforce on` or mode="enforce")."""
97
+ cfg = Config.resolve(api_key=api_key, mode=mode, frameworks=frameworks, **overrides)
98
+ client = XybernClient(cfg)
99
+
100
+ from .telemetry import Telemetry
101
+ tel = Telemetry(client, cfg)
102
+ tel.start()
103
+ registrar = _Registrar(client, cfg)
104
+
105
+ # 1) inventory whatever already exists, register each
106
+ found = discovery.discover_all(cfg.frameworks)
107
+ for a in found:
108
+ registrar.ensure(a)
109
+
110
+ # 2) the action hook used by all framework interceptors
111
+ def hook(framework, action_type, agent_name, content, metadata) -> bool:
112
+ return _on_action(cfg, client, tel, registrar, framework, action_type,
113
+ agent_name, content, metadata)
114
+
115
+ def on_discover(info):
116
+ registrar.ensure(info)
117
+
118
+ instrumented = []
119
+ if instrument:
120
+ instrumented = discovery.instrument_all(hook, on_discover, cfg.frameworks)
121
+
122
+ _STATE.update({"connected": True, "cfg": cfg, "client": client,
123
+ "telemetry": tel, "registrar": registrar})
124
+
125
+ return ConnectResult(
126
+ authenticated=cfg.authenticated, mode=cfg.mode,
127
+ frameworks=discovery.detected_frameworks(cfg.frameworks),
128
+ agents=[a.name for a in found], instrumented=instrumented,
129
+ workspace=cfg.workspace,
130
+ )
131
+
132
+
133
+ def _on_action(cfg, client, tel, registrar, framework, action_type, agent_name,
134
+ content, metadata) -> bool:
135
+ """Return True to allow, False to block. OBSERVE always allows (logs only)."""
136
+ agent_id = None
137
+ if agent_name:
138
+ from .discovery import AgentInfo
139
+ agent_id = registrar.ensure(AgentInfo(name=agent_name, framework=framework))
140
+
141
+ if cfg.mode != "enforce":
142
+ # observe: enqueue, never block
143
+ if cfg.authenticated:
144
+ ev = {"action_type": action_type, "agent_id": agent_id,
145
+ "metadata": {"framework": framework, **(metadata or {})}}
146
+ if content is not None:
147
+ ev["metadata"]["content_sha256"] = hashlib.sha256(
148
+ content.encode("utf-8", "replace")).hexdigest()
149
+ tel.record(ev)
150
+ return True
151
+
152
+ # enforce: synchronous decision, fail-open per config
153
+ if not cfg.authenticated:
154
+ return True
155
+ try:
156
+ result = client.intercept(action_type=action_type, action_content=content,
157
+ agent_id=agent_id,
158
+ metadata={"framework": framework, **(metadata or {})})
159
+ decision = result.get("decision", "allow")
160
+ return decision == "allow"
161
+ except Exception:
162
+ return cfg.fail_open
163
+
164
+
165
+ def status() -> Dict:
166
+ return {"connected": _STATE.get("connected", False),
167
+ "mode": getattr(_STATE.get("cfg"), "mode", None)}
xybern/cli/__init__.py ADDED
File without changes
xybern/cli/__main__.py ADDED
@@ -0,0 +1,158 @@
1
+ """`xybern` command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import socket
8
+ import sys
9
+ import time
10
+
11
+ from ..config import (Config, DEFAULT_BASE_URL, save_credentials, clear_credentials,
12
+ set_mode)
13
+ from ..client import XybernClient
14
+
15
+
16
+ def _client(api_key=None) -> XybernClient:
17
+ base = os.environ.get("XYBERN_BASE_URL", DEFAULT_BASE_URL)
18
+ return XybernClient(Config(api_key=api_key, base_url=base))
19
+
20
+
21
+ # ── commands ──────────────────────────────────────────────────────────────────
22
+
23
+ def cmd_login(args) -> int:
24
+ base = os.environ.get("XYBERN_BASE_URL", DEFAULT_BASE_URL)
25
+
26
+ # Path 1 — explicit/paste key (or env). Validate then save.
27
+ api_key = args.api_key or os.environ.get("XYBERN_API_KEY")
28
+ if api_key:
29
+ cli = _client(api_key)
30
+ try:
31
+ cli.whoami()
32
+ except Exception as e:
33
+ print(f"✗ Could not validate that API key: {e}", file=sys.stderr)
34
+ return 1
35
+ save_credentials(api_key, base_url=base)
36
+ print("✓ Logged in with API key. Credentials saved to ~/.xybern/credentials.json")
37
+ return 0
38
+
39
+ # Path 2 — device-code browser flow (auto-picks your workspace key)
40
+ cli = _client()
41
+ try:
42
+ start = cli.device_auth_start(hostname=socket.gethostname())
43
+ except Exception as e:
44
+ print(f"✗ Device login unavailable ({e}).\n"
45
+ f" Use: xybern login --api-key xb_... (create a key in your Xybern dashboard)",
46
+ file=sys.stderr)
47
+ return 1
48
+
49
+ url = start.get("verification_url") or start.get("verification_uri")
50
+ user_code = start.get("user_code")
51
+ device_code = start.get("device_code")
52
+ interval = int(start.get("interval", 5))
53
+ expires = int(start.get("expires_in", 600))
54
+
55
+ print("\nTo authorise this machine, open:\n")
56
+ print(f" {url}")
57
+ print(f"\nand enter the code: {user_code}\n")
58
+ try:
59
+ import webbrowser
60
+ webbrowser.open(url)
61
+ except Exception:
62
+ pass
63
+
64
+ print("Waiting for approval…", end="", flush=True)
65
+ deadline = time.time() + expires
66
+ while time.time() < deadline:
67
+ time.sleep(interval)
68
+ print(".", end="", flush=True)
69
+ try:
70
+ poll = cli.device_auth_poll(device_code)
71
+ except Exception:
72
+ continue
73
+ if poll.get("api_key"):
74
+ save_credentials(poll["api_key"], base_url=base, workspace=poll.get("workspace"))
75
+ print(f"\n✓ Logged in to workspace {poll.get('workspace') or ''}. "
76
+ f"Credentials saved.")
77
+ return 0
78
+ if poll.get("status") in ("denied", "expired"):
79
+ print(f"\n✗ Login {poll['status']}.", file=sys.stderr)
80
+ return 1
81
+ print("\n✗ Timed out waiting for approval.", file=sys.stderr)
82
+ return 1
83
+
84
+
85
+ def cmd_logout(args) -> int:
86
+ clear_credentials()
87
+ print("✓ Logged out.")
88
+ return 0
89
+
90
+
91
+ def cmd_agents(args) -> int:
92
+ from .. import discovery
93
+ fw = discovery.detected_frameworks()
94
+ found = discovery.discover_all()
95
+ print(f"✓ Detected frameworks: {', '.join(fw) or 'none'}")
96
+ print(f"✓ Found {len(found)} agent(s)/tool(s) already instantiated"
97
+ + ("" if found else " (more appear as your app creates them)"))
98
+ by_fw = {}
99
+ for a in found:
100
+ by_fw.setdefault(a.framework, []).append(a)
101
+ for f, items in by_fw.items():
102
+ print(f" • {f}: " + ", ".join(sorted({i.name for i in items}))[:200])
103
+ cfg = Config.resolve()
104
+ if not cfg.authenticated:
105
+ print("\n⚠ Not connected. Run `xybern login`, then `auto.connect()` in your app "
106
+ "to register these agents (each gets an identity).")
107
+ return 0
108
+
109
+
110
+ def cmd_status(args) -> int:
111
+ cfg = Config.resolve()
112
+ print(f"Base URL: {cfg.base_url}")
113
+ print(f"Mode: {cfg.mode}")
114
+ print(f"Auth: {'✓ logged in' if cfg.authenticated else '✗ not logged in'}")
115
+ if cfg.authenticated:
116
+ try:
117
+ n = len((_client(cfg.api_key).list_agents().get('agents') or []))
118
+ print(f"Workspace: {cfg.workspace or '(linked)'} · {n} registered agent(s)")
119
+ except Exception as e:
120
+ print(f"Workspace: (could not reach control plane: {e})")
121
+ return 0
122
+
123
+
124
+ def cmd_enforce(args) -> int:
125
+ if args.state == "on":
126
+ set_mode("enforce")
127
+ print("✓ Enforcement ON — actions will be authorised (allow/block/escalate) on next connect.")
128
+ else:
129
+ set_mode("observe")
130
+ print("✓ Enforcement OFF — observe only (actions logged, nothing blocked).")
131
+ return 0
132
+
133
+
134
+ def main(argv=None) -> int:
135
+ p = argparse.ArgumentParser(prog="xybern", description="Xybern — identity & authorisation for AI agents")
136
+ sub = p.add_subparsers(dest="cmd")
137
+
138
+ lp = sub.add_parser("login", help="Authenticate this machine (device code, or --api-key)")
139
+ lp.add_argument("--api-key", help="Use an API key directly instead of the browser flow")
140
+ lp.set_defaults(func=cmd_login)
141
+
142
+ sub.add_parser("logout", help="Remove stored credentials").set_defaults(func=cmd_logout)
143
+ sub.add_parser("agents", help="Discover agents/tools in this project (dry run)").set_defaults(func=cmd_agents)
144
+ sub.add_parser("status", help="Show login + mode").set_defaults(func=cmd_status)
145
+
146
+ ep = sub.add_parser("enforce", help="Turn enforcement on/off")
147
+ ep.add_argument("state", choices=["on", "off"])
148
+ ep.set_defaults(func=cmd_enforce)
149
+
150
+ args = p.parse_args(argv)
151
+ if not getattr(args, "func", None):
152
+ p.print_help()
153
+ return 0
154
+ return args.func(args)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ sys.exit(main())
xybern/client.py ADDED
@@ -0,0 +1,83 @@
1
+ """Thin REST client for the Xybern control plane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import requests
9
+
10
+ from .config import Config
11
+
12
+
13
+ class XybernClient:
14
+ def __init__(self, config: Config):
15
+ self.cfg = config
16
+ self._session = requests.Session()
17
+ if config.api_key:
18
+ self._session.headers.update({"X-API-Key": config.api_key})
19
+ self._session.headers.update({"User-Agent": "xybern-sdk/2.0"})
20
+
21
+ # ── low level ────────────────────────────────────────────────────────────
22
+ def _url(self, path: str) -> str:
23
+ return f"{self.cfg.base_url.rstrip('/')}/{path.lstrip('/')}"
24
+
25
+ def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
26
+ r = self._session.post(self._url(path), json=body, timeout=self.cfg.timeout)
27
+ r.raise_for_status()
28
+ return r.json()
29
+
30
+ def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
31
+ r = self._session.get(self._url(path), params=params or {}, timeout=self.cfg.timeout)
32
+ r.raise_for_status()
33
+ return r.json()
34
+
35
+ # ── agents / registration ────────────────────────────────────────────────
36
+ def register_agent(self, name: str, framework: str = "custom",
37
+ description: Optional[str] = None,
38
+ capabilities: Optional[List[str]] = None,
39
+ permissions: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
40
+ """Register (or idempotently re-register) an agent; returns agent + credential."""
41
+ return self._post("/enforce/agents", {
42
+ "name": name,
43
+ "framework": framework,
44
+ "description": description,
45
+ "capabilities": capabilities or [],
46
+ "permissions": permissions or {},
47
+ })
48
+
49
+ def list_agents(self) -> Dict[str, Any]:
50
+ return self._get("/enforce/agents")
51
+
52
+ # ── enforcement ────────────────────────────────────────────────────────────
53
+ def intercept(self, action_type: str, action_content: Optional[str] = None,
54
+ agent_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
55
+ ) -> Dict[str, Any]:
56
+ """Authorise one action. Respects redact (sends a content hash by default)."""
57
+ body: Dict[str, Any] = {"action_type": action_type, "agent_id": agent_id,
58
+ "metadata": metadata or {}}
59
+ if action_content is not None:
60
+ if self.cfg.redact:
61
+ body["metadata"]["content_sha256"] = hashlib.sha256(
62
+ action_content.encode("utf-8", "replace")).hexdigest()
63
+ body["action_content"] = None
64
+ else:
65
+ body["action_content"] = action_content
66
+ return self._post("/enforce/intercept", body)
67
+
68
+ def batch_intercept(self, actions: List[Dict[str, Any]]) -> Dict[str, Any]:
69
+ return self._post("/enforce/batch", {"actions": actions})
70
+
71
+ def list_policies(self) -> Dict[str, Any]:
72
+ return self._get("/enforce/policies")
73
+
74
+ def whoami(self) -> Dict[str, Any]:
75
+ """Lightweight auth/workspace check (lists agents; 200 => key valid)."""
76
+ return self._get("/enforce/agents")
77
+
78
+ # ── device-code login ──────────────────────────────────────────────────────
79
+ def device_auth_start(self, hostname: str) -> Dict[str, Any]:
80
+ return self._post("/auth/device/start", {"hostname": hostname, "client": "xybern-sdk"})
81
+
82
+ def device_auth_poll(self, device_code: str) -> Dict[str, Any]:
83
+ return self._post("/auth/device/poll", {"device_code": device_code})
xybern/config.py ADDED
@@ -0,0 +1,91 @@
1
+ """Configuration + credential resolution for the Xybern SDK.
2
+
3
+ Resolution order for the API key (first hit wins):
4
+ 1. explicit api_key=... passed to auto.connect()/Config
5
+ 2. env XYBERN_API_KEY
6
+ 3. ~/.xybern/credentials.json (written by `xybern login`)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Optional, Dict, Any
16
+
17
+ DEFAULT_BASE_URL = "https://www.xybern.com/api/v1"
18
+ CONFIG_DIR = Path(os.environ.get("XYBERN_HOME", str(Path.home() / ".xybern")))
19
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
20
+
21
+
22
+ def _read_credentials_file() -> Dict[str, Any]:
23
+ try:
24
+ return json.loads(CREDENTIALS_FILE.read_text())
25
+ except Exception:
26
+ return {}
27
+
28
+
29
+ def save_credentials(api_key: str, base_url: str = DEFAULT_BASE_URL,
30
+ workspace: Optional[str] = None) -> None:
31
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
32
+ CREDENTIALS_FILE.write_text(json.dumps({
33
+ "api_key": api_key, "base_url": base_url, "workspace": workspace,
34
+ }, indent=2))
35
+ try:
36
+ os.chmod(CREDENTIALS_FILE, 0o600) # don't leave the key world-readable
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ def clear_credentials() -> None:
42
+ try:
43
+ CREDENTIALS_FILE.unlink()
44
+ except Exception:
45
+ pass
46
+
47
+
48
+ def set_mode(mode: str) -> None:
49
+ """Persist the enforcement mode (observe|enforce) for future connects."""
50
+ creds = _read_credentials_file()
51
+ creds["mode"] = mode
52
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
53
+ CREDENTIALS_FILE.write_text(json.dumps(creds, indent=2))
54
+
55
+
56
+ @dataclass
57
+ class Config:
58
+ """Resolved SDK configuration."""
59
+ api_key: Optional[str] = None
60
+ base_url: str = DEFAULT_BASE_URL
61
+ # "observe" = log/registry only, never blocks. "enforce" = act on decisions.
62
+ mode: str = "observe"
63
+ # If Xybern is unreachable, allow the action through (True) or block (False).
64
+ fail_open: bool = True
65
+ # Send hashes of action content instead of raw content.
66
+ redact: bool = True
67
+ # Frameworks to instrument; None = all detected. [] = none (discovery only).
68
+ frameworks: Optional[list] = None
69
+ timeout: float = 10.0
70
+ workspace: Optional[str] = None
71
+ extra: Dict[str, Any] = field(default_factory=dict)
72
+
73
+ @classmethod
74
+ def resolve(cls, api_key: Optional[str] = None, **overrides) -> "Config":
75
+ creds = _read_credentials_file()
76
+ key = api_key or os.environ.get("XYBERN_API_KEY") or creds.get("api_key")
77
+ base = (overrides.pop("base_url", None)
78
+ or os.environ.get("XYBERN_BASE_URL")
79
+ or creds.get("base_url") or DEFAULT_BASE_URL)
80
+ mode = (overrides.pop("mode", None) or os.environ.get("XYBERN_MODE")
81
+ or creds.get("mode") or "observe")
82
+ cfg = cls(api_key=key, base_url=base, mode=mode,
83
+ workspace=creds.get("workspace"))
84
+ for k, v in overrides.items():
85
+ if hasattr(cfg, k) and v is not None:
86
+ setattr(cfg, k, v)
87
+ return cfg
88
+
89
+ @property
90
+ def authenticated(self) -> bool:
91
+ return bool(self.api_key and self.api_key.startswith("xb_"))
@@ -0,0 +1,109 @@
1
+ """Framework auto-discovery + instrumentation.
2
+
3
+ We never "scan the machine". We inspect what's *already importable / imported* in
4
+ the running process and (when instrumenting) hook the creation + execution points
5
+ of supported frameworks. Everything is best-effort and defensive: a detector that
6
+ can't find an expected internal simply reports presence and no-ops — it must never
7
+ crash the host application.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import importlib.util
14
+ import sys
15
+ from dataclasses import dataclass, field
16
+ from typing import Callable, List, Optional
17
+
18
+
19
+ @dataclass
20
+ class AgentInfo:
21
+ name: str
22
+ framework: str
23
+ kind: str = "agent" # agent | tool | workflow | mcp_server
24
+ capabilities: List[str] = field(default_factory=list)
25
+
26
+ def fingerprint(self) -> str:
27
+ """Stable id for idempotent registration (survives restarts)."""
28
+ raw = f"{self.framework}|{self.kind}|{self.name}"
29
+ return "xagt_" + hashlib.sha256(raw.encode()).hexdigest()[:20]
30
+
31
+
32
+ # Callback the host process calls before an action executes.
33
+ # Signature: (framework, action_type, agent_name, content, metadata) -> bool(allow)
34
+ ActionHook = Callable[[str, str, Optional[str], Optional[str], dict], bool]
35
+
36
+
37
+ class Detector:
38
+ framework: str = "base"
39
+ module_hint: str = "" # top-level module that signals presence
40
+
41
+ def available(self) -> bool:
42
+ try:
43
+ if self.module_hint in sys.modules:
44
+ return True
45
+ return importlib.util.find_spec(self.module_hint) is not None
46
+ except Exception:
47
+ return False
48
+
49
+ def imported(self) -> bool:
50
+ return self.module_hint in sys.modules
51
+
52
+ def scan(self) -> List[AgentInfo]:
53
+ """Best-effort inventory of already-instantiated agents/tools."""
54
+ return []
55
+
56
+ def instrument(self, hook: ActionHook, on_discover: Callable[[AgentInfo], None]) -> bool:
57
+ """Hook creation/execution points. Return True if any hook was installed."""
58
+ return False
59
+
60
+
61
+ # ── registry ────────────────────────────────────────────────────────────────
62
+
63
+ def _all_detectors() -> List[Detector]:
64
+ from . import detectors # late import to avoid cycles
65
+ return detectors.build()
66
+
67
+
68
+ def get_detectors(frameworks: Optional[List[str]] = None,
69
+ available_only: bool = True) -> List[Detector]:
70
+ dets = _all_detectors()
71
+ if frameworks is not None:
72
+ wanted = {f.lower() for f in frameworks}
73
+ dets = [d for d in dets if d.framework.lower() in wanted]
74
+ if available_only:
75
+ dets = [d for d in dets if _safe(d.available, default=False)]
76
+ return dets
77
+
78
+
79
+ def discover_all(frameworks: Optional[List[str]] = None) -> List[AgentInfo]:
80
+ found: List[AgentInfo] = []
81
+ for d in get_detectors(frameworks):
82
+ found.extend(_safe(d.scan, default=[]) or [])
83
+ # de-dupe by fingerprint
84
+ seen, out = set(), []
85
+ for a in found:
86
+ fp = a.fingerprint()
87
+ if fp not in seen:
88
+ seen.add(fp); out.append(a)
89
+ return out
90
+
91
+
92
+ def detected_frameworks(frameworks: Optional[List[str]] = None) -> List[str]:
93
+ return [d.framework for d in get_detectors(frameworks)]
94
+
95
+
96
+ def instrument_all(hook: ActionHook, on_discover: Callable[[AgentInfo], None],
97
+ frameworks: Optional[List[str]] = None) -> List[str]:
98
+ instrumented = []
99
+ for d in get_detectors(frameworks):
100
+ if _safe(lambda: d.instrument(hook, on_discover), default=False):
101
+ instrumented.append(d.framework)
102
+ return instrumented
103
+
104
+
105
+ def _safe(fn, default=None):
106
+ try:
107
+ return fn()
108
+ except Exception:
109
+ return default
@@ -0,0 +1,358 @@
1
+ """Per-framework detectors. Every hook is defensive — guarded by hasattr and
2
+ wrapped so a version mismatch degrades to "presence detected" rather than crashing
3
+ the host application."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import gc
8
+ from typing import Callable, List
9
+
10
+ from . import AgentInfo, Detector, ActionHook
11
+
12
+
13
+ # ── shared helpers ────────────────────────────────────────────────────────────
14
+
15
+ def _wrap(cls, method_name: str, make_wrapper) -> bool:
16
+ """Replace cls.method_name with make_wrapper(orig); idempotent."""
17
+ orig = getattr(cls, method_name, None)
18
+ if orig is None or getattr(orig, "_xybern_wrapped", False):
19
+ return False
20
+ wrapper = make_wrapper(orig)
21
+ try:
22
+ wrapper._xybern_wrapped = True
23
+ except Exception:
24
+ pass
25
+ setattr(cls, method_name, wrapper)
26
+ return True
27
+
28
+
29
+ def _enforce(hook: ActionHook, framework: str, action_type: str,
30
+ agent: str = None, content: str = None, meta: dict = None):
31
+ """Call the host hook; raise PolicyBlocked if it denies. Never lets unrelated errors propagate."""
32
+ try:
33
+ allow = hook(framework, action_type or "action", agent, content, meta or {})
34
+ except Exception:
35
+ return # telemetry/transport hiccup must not break the agent (fail-open handled upstream)
36
+ if allow is False:
37
+ from ..exceptions import PolicyBlocked
38
+ raise PolicyBlocked(action_type or "action")
39
+
40
+
41
+ def _scan(cls, framework: str, kind: str, name_attrs: List[str]) -> List[AgentInfo]:
42
+ out = []
43
+ try:
44
+ for o in gc.get_objects():
45
+ if isinstance(o, cls):
46
+ nm = None
47
+ for at in name_attrs:
48
+ nm = getattr(o, at, None)
49
+ if nm:
50
+ break
51
+ out.append(AgentInfo(name=str(nm or type(o).__name__), framework=framework, kind=kind))
52
+ except Exception:
53
+ pass
54
+ return out
55
+
56
+
57
+ # ── LangChain ────────────────────────────────────────────────────────────────
58
+
59
+ class LangChainDetector(Detector):
60
+ framework = "langchain"
61
+ module_hint = "langchain_core"
62
+
63
+ def _tool_cls(self):
64
+ import langchain_core.tools as t
65
+ return t.BaseTool
66
+
67
+ def scan(self):
68
+ if not self.imported():
69
+ return []
70
+ try:
71
+ return _scan(self._tool_cls(), self.framework, "tool", ["name"])
72
+ except Exception:
73
+ return []
74
+
75
+ def instrument(self, hook, on_discover):
76
+ import langchain_core.tools as t
77
+ BaseTool = t.BaseTool
78
+ ok = False
79
+ for m in ("run", "arun", "_run", "invoke"):
80
+ def mk(orig):
81
+ def wrapper(self, *a, **k):
82
+ name = getattr(self, "name", type(self).__name__)
83
+ try:
84
+ on_discover(AgentInfo(name=name, framework="langchain", kind="tool"))
85
+ except Exception:
86
+ pass
87
+ _enforce(hook, "langchain", str(name), content=_firststr(a, k))
88
+ return orig(self, *a, **k)
89
+ return wrapper
90
+ ok = _wrap(BaseTool, m, mk) or ok
91
+ return ok
92
+
93
+
94
+ # ── CrewAI ──────────────────────────────────────────────────────────────────
95
+
96
+ class CrewAIDetector(Detector):
97
+ framework = "crewai"
98
+ module_hint = "crewai"
99
+
100
+ def scan(self):
101
+ if not self.imported():
102
+ return []
103
+ try:
104
+ import crewai
105
+ return _scan(crewai.Agent, self.framework, "agent", ["role", "name"])
106
+ except Exception:
107
+ return []
108
+
109
+ def instrument(self, hook, on_discover):
110
+ import crewai
111
+ ok = False
112
+
113
+ def mk_init(orig):
114
+ def wrapper(self, *a, **k):
115
+ orig(self, *a, **k)
116
+ try:
117
+ on_discover(AgentInfo(name=str(getattr(self, "role", None) or "crew-agent"),
118
+ framework="crewai", kind="agent",
119
+ capabilities=[getattr(t, "name", "") for t in (getattr(self, "tools", []) or [])]))
120
+ except Exception:
121
+ pass
122
+ return wrapper
123
+ ok = _wrap(crewai.Agent, "__init__", mk_init) or ok
124
+
125
+ for meth in ("execute_task",):
126
+ def mk(orig):
127
+ def wrapper(self, *a, **k):
128
+ _enforce(hook, "crewai", "execute_task",
129
+ agent=str(getattr(self, "role", None) or "crew-agent"))
130
+ return orig(self, *a, **k)
131
+ return wrapper
132
+ ok = _wrap(crewai.Agent, meth, mk) or ok
133
+ return ok
134
+
135
+
136
+ # ── OpenAI Agents SDK (`agents` package) ──────────────────────────────────────
137
+
138
+ class OpenAIAgentsDetector(Detector):
139
+ framework = "openai-agents"
140
+ module_hint = "agents"
141
+
142
+ def scan(self):
143
+ if not self.imported():
144
+ return []
145
+ try:
146
+ import agents
147
+ return _scan(agents.Agent, self.framework, "agent", ["name"])
148
+ except Exception:
149
+ return []
150
+
151
+ def instrument(self, hook, on_discover):
152
+ import agents
153
+ ok = False
154
+
155
+ def mk_init(orig):
156
+ def wrapper(self, *a, **k):
157
+ orig(self, *a, **k)
158
+ try:
159
+ on_discover(AgentInfo(name=str(getattr(self, "name", "agent")),
160
+ framework="openai-agents", kind="agent"))
161
+ except Exception:
162
+ pass
163
+ return wrapper
164
+ ok = _wrap(getattr(agents, "Agent", object), "__init__", mk_init) or ok
165
+
166
+ # Function tools expose on_invoke_tool / a callable; wrap if present.
167
+ FunctionTool = getattr(agents, "FunctionTool", None)
168
+ if FunctionTool is not None:
169
+ for meth in ("on_invoke_tool", "invoke", "run"):
170
+ def mk(orig):
171
+ def wrapper(self, *a, **k):
172
+ _enforce(hook, "openai-agents", str(getattr(self, "name", "tool")))
173
+ return orig(self, *a, **k)
174
+ return wrapper
175
+ ok = _wrap(FunctionTool, meth, mk) or ok
176
+ return ok
177
+
178
+
179
+ # ── MCP servers ───────────────────────────────────────────────────────────────
180
+
181
+ class MCPDetector(Detector):
182
+ framework = "mcp"
183
+ module_hint = "mcp"
184
+
185
+ def instrument(self, hook, on_discover):
186
+ ok = False
187
+ # FastMCP tool calls
188
+ try:
189
+ from mcp.server.fastmcp import FastMCP
190
+ for meth in ("call_tool",):
191
+ def mk(orig):
192
+ def wrapper(self, name, *a, **k):
193
+ try:
194
+ on_discover(AgentInfo(name=str(name), framework="mcp", kind="mcp_server"))
195
+ except Exception:
196
+ pass
197
+ _enforce(hook, "mcp", str(name))
198
+ return orig(self, name, *a, **k)
199
+ return wrapper
200
+ ok = _wrap(FastMCP, meth, mk) or ok
201
+ except Exception:
202
+ pass
203
+ # Low-level server
204
+ try:
205
+ from mcp.server import Server
206
+ for meth in ("call_tool",):
207
+ def mk(orig):
208
+ def wrapper(self, *a, **k):
209
+ _enforce(hook, "mcp", "call_tool")
210
+ return orig(self, *a, **k)
211
+ return wrapper
212
+ ok = _wrap(Server, meth, mk) or ok
213
+ except Exception:
214
+ pass
215
+ return ok
216
+
217
+
218
+ # ── LangGraph ─────────────────────────────────────────────────────────────────
219
+
220
+ class LangGraphDetector(Detector):
221
+ framework = "langgraph"
222
+ module_hint = "langgraph"
223
+
224
+ def instrument(self, hook, on_discover):
225
+ ok = False
226
+ try:
227
+ from langgraph.graph import StateGraph
228
+
229
+ def mk(orig):
230
+ def wrapper(self, node, *a, **k):
231
+ try:
232
+ on_discover(AgentInfo(name=str(node), framework="langgraph", kind="workflow"))
233
+ except Exception:
234
+ pass
235
+ return orig(self, node, *a, **k)
236
+ return wrapper
237
+ ok = _wrap(StateGraph, "add_node", mk) or ok
238
+ except Exception:
239
+ pass
240
+ return ok
241
+
242
+
243
+ # ── AutoGen ───────────────────────────────────────────────────────────────────
244
+
245
+ class AutoGenDetector(Detector):
246
+ framework = "autogen"
247
+ module_hint = "autogen"
248
+
249
+ def instrument(self, hook, on_discover):
250
+ ok = False
251
+ try:
252
+ import autogen
253
+ CA = getattr(autogen, "ConversableAgent", None)
254
+ if CA is not None:
255
+ def mk_init(orig):
256
+ def wrapper(self, *a, **k):
257
+ orig(self, *a, **k)
258
+ try:
259
+ on_discover(AgentInfo(name=str(getattr(self, "name", "autogen-agent")),
260
+ framework="autogen", kind="agent"))
261
+ except Exception:
262
+ pass
263
+ return wrapper
264
+ ok = _wrap(CA, "__init__", mk_init) or ok
265
+
266
+ def mk(orig):
267
+ def wrapper(self, *a, **k):
268
+ _enforce(hook, "autogen", "execute_function",
269
+ agent=str(getattr(self, "name", "autogen-agent")))
270
+ return orig(self, *a, **k)
271
+ return wrapper
272
+ for meth in ("execute_function",):
273
+ ok = _wrap(CA, meth, mk) or ok
274
+ except Exception:
275
+ pass
276
+ return ok
277
+
278
+
279
+ # ── Semantic Kernel ─────────────────────────────────────────────────────────
280
+
281
+ class SemanticKernelDetector(Detector):
282
+ framework = "semantic-kernel"
283
+ module_hint = "semantic_kernel"
284
+
285
+ def instrument(self, hook, on_discover):
286
+ ok = False
287
+ try:
288
+ from semantic_kernel.functions.kernel_function import KernelFunction
289
+ for meth in ("invoke", "__call__"):
290
+ def mk(orig):
291
+ def wrapper(self, *a, **k):
292
+ name = getattr(self, "name", None) or getattr(self, "fully_qualified_name", "kernel-function")
293
+ try:
294
+ on_discover(AgentInfo(name=str(name), framework="semantic-kernel", kind="tool"))
295
+ except Exception:
296
+ pass
297
+ _enforce(hook, "semantic-kernel", str(name))
298
+ return orig(self, *a, **k)
299
+ return wrapper
300
+ ok = _wrap(KernelFunction, meth, mk) or ok
301
+ except Exception:
302
+ pass
303
+ return ok
304
+
305
+
306
+ # ── LlamaIndex ────────────────────────────────────────────────────────────────
307
+
308
+ class LlamaIndexDetector(Detector):
309
+ framework = "llamaindex"
310
+ module_hint = "llama_index"
311
+
312
+ def instrument(self, hook, on_discover):
313
+ ok = False
314
+ try:
315
+ from llama_index.core.tools import FunctionTool
316
+ for meth in ("call", "__call__", "acall"):
317
+ def mk(orig):
318
+ def wrapper(self, *a, **k):
319
+ meta = getattr(self, "metadata", None)
320
+ name = getattr(meta, "name", None) if meta else "llamaindex-tool"
321
+ try:
322
+ on_discover(AgentInfo(name=str(name), framework="llamaindex", kind="tool"))
323
+ except Exception:
324
+ pass
325
+ _enforce(hook, "llamaindex", str(name))
326
+ return orig(self, *a, **k)
327
+ return wrapper
328
+ ok = _wrap(FunctionTool, meth, mk) or ok
329
+ except Exception:
330
+ pass
331
+ return ok
332
+
333
+
334
+ # ── Generic: FastAPI + Celery presence (deep hooks land in a later pass) ──────
335
+
336
+ class FastAPIDetector(Detector):
337
+ framework = "fastapi"
338
+ module_hint = "fastapi"
339
+
340
+
341
+ class CeleryDetector(Detector):
342
+ framework = "celery"
343
+ module_hint = "celery"
344
+
345
+
346
+ def build() -> List[Detector]:
347
+ return [
348
+ LangChainDetector(), CrewAIDetector(), OpenAIAgentsDetector(), MCPDetector(),
349
+ LangGraphDetector(), AutoGenDetector(), SemanticKernelDetector(), LlamaIndexDetector(),
350
+ FastAPIDetector(), CeleryDetector(),
351
+ ]
352
+
353
+
354
+ def _firststr(args, kwargs, limit: int = 2000):
355
+ for v in list(args) + list(kwargs.values()):
356
+ if isinstance(v, str):
357
+ return v[:limit]
358
+ return None
xybern/exceptions.py ADDED
@@ -0,0 +1,21 @@
1
+ """Xybern SDK exceptions."""
2
+
3
+
4
+ class XybernError(Exception):
5
+ pass
6
+
7
+
8
+ class PolicyBlocked(XybernError):
9
+ """Raised when an action is blocked by an enforced policy."""
10
+ def __init__(self, action_type: str, reason: str = ""):
11
+ self.action_type = action_type
12
+ self.reason = reason
13
+ super().__init__(f"Xybern blocked action '{action_type}'" + (f": {reason}" if reason else ""))
14
+
15
+
16
+ class PolicyEscalated(XybernError):
17
+ """Raised when an action requires human approval before it can proceed."""
18
+ def __init__(self, action_type: str, escalation_id: str = ""):
19
+ self.action_type = action_type
20
+ self.escalation_id = escalation_id
21
+ super().__init__(f"Xybern escalated action '{action_type}' for human review")
xybern/telemetry.py ADDED
@@ -0,0 +1,52 @@
1
+ """Async, fail-open telemetry for OBSERVE mode.
2
+
3
+ In observe mode every intercepted action is enqueued and flushed in batches to the
4
+ control plane (decisions are recorded server-side; the SDK never blocks). The queue
5
+ is bounded and lossy under pressure — telemetry must never slow down or crash the
6
+ host agent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import queue
12
+ import threading
13
+ from typing import Any, Dict
14
+
15
+
16
+ class Telemetry:
17
+ def __init__(self, client, config, batch_size: int = 50, flush_interval: float = 2.0):
18
+ self.client = client
19
+ self.cfg = config
20
+ self.batch_size = batch_size
21
+ self.flush_interval = flush_interval
22
+ self._q: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=10000)
23
+ self._started = False
24
+
25
+ def start(self):
26
+ if self._started or not self.cfg.authenticated:
27
+ return
28
+ self._started = True
29
+ threading.Thread(target=self._loop, daemon=True, name="xybern-telemetry").start()
30
+
31
+ def record(self, event: Dict[str, Any]):
32
+ try:
33
+ self._q.put_nowait(event)
34
+ except queue.Full:
35
+ pass # drop under pressure — never block the agent
36
+
37
+ def _loop(self):
38
+ batch = []
39
+ while True:
40
+ try:
41
+ batch.append(self._q.get(timeout=self.flush_interval))
42
+ except queue.Empty:
43
+ pass
44
+ if batch and (len(batch) >= self.batch_size or self._q.empty()):
45
+ self._flush(batch)
46
+ batch = []
47
+
48
+ def _flush(self, batch):
49
+ try:
50
+ self.client.batch_intercept(batch)
51
+ except Exception:
52
+ pass # fail-open: a telemetry outage must not affect the host
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: xybern
3
+ Version: 2.0.0
4
+ Summary: Identity & authorisation infrastructure for AI agents — install once, discover every agent, authorise every action.
5
+ Author-email: Xybern <info@xybern.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://www.xybern.com
8
+ Project-URL: Documentation, https://docs.xybern.com/authorization/sdk
9
+ Keywords: ai,agents,authorization,identity,governance,langchain,crewai,mcp,provenance
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: requests>=2.25.0
13
+ Provides-Extra: crypto
14
+ Requires-Dist: cryptography>=41.0.0; extra == "crypto"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest; extra == "dev"
17
+
18
+ # Xybern
19
+
20
+ **Identity & authorisation infrastructure for AI agents.** Install once — Xybern
21
+ discovers every AI agent in your system, gives each a cryptographic identity, and
22
+ (when you turn enforcement on) authorises every action *before* it executes.
23
+
24
+ ```bash
25
+ pip install xybern
26
+ xybern login # browser device-code flow (auto-links your workspace)
27
+ ```
28
+
29
+ ```python
30
+ from xybern import auto
31
+ auto.connect() # discovers frameworks + agents + tools, registers them, instruments them
32
+ ```
33
+
34
+ ```
35
+ ✓ Detected frameworks: CrewAI, LangGraph, 2 MCP servers
36
+ ✓ Found 12 agents · 48 tools
37
+ ✓ Registered to workspace "Acme Corp" (each issued a cryptographic identity)
38
+ ✓ Mode: OBSERVE — actions logged, nothing blocked yet
39
+ ```
40
+
41
+ ## What it does
42
+
43
+ - **Auto-discovery** — detects and instruments LangChain, CrewAI, OpenAI Agents SDK,
44
+ MCP servers, LangGraph, AutoGen, Semantic Kernel, LlamaIndex (and FastAPI/Celery).
45
+ No manual wiring; agents appear in your Xybern dashboard as your app creates them.
46
+ - **Cryptographic identity** — every discovered agent is registered and issued an
47
+ identity, so its actions are attributable and signable.
48
+ - **Authorisation before execution** — each tool/agent action passes through Xybern's
49
+ policy engine; `allow` / `block` / `escalate`.
50
+ - **Observe-first & fail-open** — default mode only *logs* (never blocks). When you
51
+ switch to enforce, the SDK fails open if Xybern is unreachable, so it can't take
52
+ your agents down.
53
+ - **Privacy** — sends content **hashes** by default, not raw payloads.
54
+
55
+ ## Modes
56
+
57
+ ```python
58
+ auto.connect() # OBSERVE (default): log + inventory, never blocks
59
+ auto.connect(mode="enforce") # authorise actions (allow/block/escalate)
60
+ ```
61
+ or persist it: `xybern enforce on` / `xybern enforce off`.
62
+
63
+ ## CLI
64
+
65
+ ```
66
+ xybern login [--api-key xb_...] # device-code flow, or paste a key / set XYBERN_API_KEY
67
+ xybern agents # dry-run: what would be discovered
68
+ xybern status
69
+ xybern enforce on|off
70
+ xybern logout
71
+ ```
72
+
73
+ ## Auth options
74
+
75
+ 1. **Device code** — `xybern login` opens a browser; approve + pick a workspace; a
76
+ scoped key is minted and stored in `~/.xybern/credentials.json`.
77
+ 2. **API key** — `xybern login --api-key xb_...`, or `export XYBERN_API_KEY=xb_...`,
78
+ or `auto.connect(api_key="xb_...")`.
79
+
80
+ ## Configuration
81
+
82
+ | Option | Default | Meaning |
83
+ | --- | --- | --- |
84
+ | `mode` | `observe` | `observe` (log only) or `enforce` (act on decisions) |
85
+ | `fail_open` | `True` | allow actions through if Xybern is unreachable (enforce mode) |
86
+ | `redact` | `True` | send content hashes instead of raw content |
87
+ | `frameworks` | all | restrict to specific frameworks |
88
+
89
+ Docs: https://docs.xybern.com/authorization/sdk
@@ -0,0 +1,15 @@
1
+ xybern/__init__.py,sha256=nxrIqGsOgmvHqtczfNK11U3uLihGCfgkPEAb5aabRG4,900
2
+ xybern/auto.py,sha256=23m3SKxcnRD5jFwpcIDpMoUXbaFiH4SaDGz1Zp56ZQs,6150
3
+ xybern/client.py,sha256=wUFwAXPd5gIni4PcKfe9GX3o1vUUu54kaBQ1YCjGXSY,4101
4
+ xybern/config.py,sha256=C7NYFQueJPHnM8NFN2cv8zkyF7lH0rgyjl0yRsPlc_E,3161
5
+ xybern/exceptions.py,sha256=a7z4TbXcrRnFXDLHj9drirbppS9_3gqcz6t1cL045r0,755
6
+ xybern/telemetry.py,sha256=y7i-W2-C6b4elVvptJfm9jRzli-A3Bwh4BzNO9OPbOs,1703
7
+ xybern/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ xybern/cli/__main__.py,sha256=E0baJXPUsl46TLI6SePv6czIe_z56HwmDrSbVNstqo4,5766
9
+ xybern/discovery/__init__.py,sha256=5PIPdy42m6n2pctoW5VD8t4mXRayPSN4VO0u2zhsaRQ,3775
10
+ xybern/discovery/detectors.py,sha256=dd0iuNi6TlPaZuR9gGakimAE_UxUBGk2Wg2GzYiw6OU,13641
11
+ xybern-2.0.0.dist-info/METADATA,sha256=DDdYvt95jaESmYCmIhBdsxDRsTmvrc8zuDnSaa5ofJs,3386
12
+ xybern-2.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ xybern-2.0.0.dist-info/entry_points.txt,sha256=80JV-7qZiC55cjgBSO9tzpsVgZlOwyzvUHEkyE3YNTs,52
14
+ xybern-2.0.0.dist-info/top_level.txt,sha256=VjRinkGTjBhdhPoKoZAk521u6oOVLYFvkoFlWT_umPY,7
15
+ xybern-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ xybern = xybern.cli.__main__:main
@@ -0,0 +1 @@
1
+ xybern