xcorecli 1.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.
@@ -0,0 +1,213 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+ # ── Templates src/main.py ─────────────────────────────────────
6
+
7
+ _TRUSTED_TEMPLATE = '''\
8
+ from xcore.kernel.api.contract import TrustedBase, error, ok
9
+
10
+
11
+ class Plugin(TrustedBase):
12
+ """
13
+ {description}
14
+ Execution mode : trusted — full service injection via self.get_service().
15
+ """
16
+
17
+ async def on_load(self) -> None:
18
+ # Access services here, e.g.:
19
+ # self.db = self.get_service("db")
20
+ # self.cache = self.get_service("cache")
21
+ pass
22
+
23
+ async def on_reload(self) -> None:
24
+ pass
25
+
26
+ async def on_unload(self) -> None:
27
+ pass
28
+
29
+ async def handle(self, action: str, payload: dict) -> dict:
30
+ match action:
31
+ case "ping":
32
+ return ok(message="pong")
33
+ case _:
34
+ return error(f"unknown action: {{action!r}}")
35
+
36
+ def get_router(self):
37
+ """Expose custom HTTP routes under /plugin/{name}/."""
38
+ from fastapi import APIRouter
39
+
40
+ router = APIRouter(prefix="/", tags=["{name}"])
41
+
42
+ @router.get("/")
43
+ async def index():
44
+ return {{"plugin": "{name}", "status": "ok"}}
45
+
46
+ return router
47
+ '''
48
+
49
+ _SANDBOXED_TEMPLATE = '''\
50
+ from xcore.kernel.api.contract import error, ok
51
+
52
+
53
+ class Plugin:
54
+ """
55
+ {description}
56
+ Execution mode : sandboxed — runs in an isolated subprocess.
57
+ No direct service injection; use IPC if needed.
58
+ """
59
+
60
+ _config: dict = {{}}
61
+
62
+ async def handle(self, action: str, payload: dict) -> dict:
63
+ match action:
64
+ case "ping":
65
+ return ok(message="pong")
66
+ case _:
67
+ return error(f"unknown action: {{action!r}}")
68
+ '''
69
+
70
+ _LEGACY_TEMPLATE = '''\
71
+ from xcore.kernel.api.contract import error, ok
72
+
73
+
74
+ class Plugin:
75
+ """
76
+ {description}
77
+ Execution mode : legacy — duck-typed, minimal lifecycle hooks.
78
+ """
79
+
80
+ _config: dict = {{}}
81
+
82
+ async def on_init(self) -> None:
83
+ pass
84
+
85
+ async def on_start(self) -> None:
86
+ pass
87
+
88
+ async def on_stop(self) -> None:
89
+ pass
90
+
91
+ async def handle(self, action: str, payload: dict) -> dict:
92
+ match action:
93
+ case "ping":
94
+ return ok(message="pong")
95
+ case _:
96
+ return error(f"unknown action: {{action!r}}")
97
+ '''
98
+
99
+ _TEMPLATES = {
100
+ "trusted": _TRUSTED_TEMPLATE,
101
+ "sandboxed": _SANDBOXED_TEMPLATE,
102
+ "legacy": _LEGACY_TEMPLATE,
103
+ }
104
+
105
+
106
+ # ── Manifest builder ──────────────────────────────────────────
107
+
108
+ def _build_manifest(cfg: dict) -> dict:
109
+ manifest: dict = {
110
+ "name": cfg["name"],
111
+ "version": cfg["version"],
112
+ "author": cfg["author"],
113
+ "description": cfg["description"],
114
+ "framework_version": cfg["framework_version"],
115
+ "execution_mode": cfg["execution_mode"],
116
+ "entry_point": cfg["entry_point"],
117
+ }
118
+
119
+ if cfg.get("allowed_imports"):
120
+ manifest["allowed_imports"] = cfg["allowed_imports"]
121
+
122
+ if cfg.get("requires"):
123
+ manifest["requires"] = cfg["requires"]
124
+
125
+ if cfg.get("env"):
126
+ manifest["env"] = cfg["env"]
127
+ manifest["envconfiguration"] = {"inject": True, "env_file": ".env"}
128
+
129
+ resources: dict = {}
130
+ if cfg.get("timeout_seconds", 10) != 10:
131
+ resources["timeout_seconds"] = cfg["timeout_seconds"]
132
+ if cfg.get("max_memory_mb", 128) != 128:
133
+ resources["max_memory_mb"] = cfg["max_memory_mb"]
134
+ if cfg.get("max_disk_mb", 50) != 50:
135
+ resources["max_disk_mb"] = cfg["max_disk_mb"]
136
+ rl_calls = cfg.get("rate_limit_calls", 100)
137
+ rl_period = cfg.get("rate_limit_period", 60)
138
+ if rl_calls != 100 or rl_period != 60:
139
+ resources["rate_limit"] = {"calls": rl_calls, "period_seconds": rl_period}
140
+ if resources:
141
+ manifest["resources"] = resources
142
+
143
+ runtime: dict = {}
144
+ if not cfg.get("healthcheck_enabled", True):
145
+ runtime["health_check"] = {"enabled": False}
146
+ if cfg.get("retry_attempts", 1) > 1:
147
+ runtime.setdefault("retry", {})["max_attempts"] = cfg["retry_attempts"]
148
+ if runtime:
149
+ manifest["runtime"] = runtime
150
+
151
+ allowed_paths = cfg.get("filesystem_allowed", ["data/"])
152
+ denied_paths = cfg.get("filesystem_denied", ["src/"])
153
+ if allowed_paths != ["data/"] or denied_paths != ["src/"]:
154
+ manifest["filesystem"] = {
155
+ "allowed_paths": allowed_paths,
156
+ "denied_paths": denied_paths,
157
+ }
158
+
159
+ if cfg.get("permissions"):
160
+ manifest["permissions"] = cfg["permissions"]
161
+
162
+ return manifest
163
+
164
+
165
+ # ── File writer ───────────────────────────────────────────────
166
+
167
+ def scaffold(cfg: dict, target_dir: Path) -> list[Path]:
168
+ """Generate the plugin directory structure and return created paths."""
169
+ cfg = {
170
+ "version": "0.1.0",
171
+ "author": "unknown",
172
+ "description": "",
173
+ "framework_version": ">=2.0",
174
+ "entry_point": "src/main.py",
175
+ "allowed_imports": [],
176
+ "requires": [],
177
+ "env": {},
178
+ "permissions": [],
179
+ **cfg,
180
+ }
181
+ created: list[Path] = []
182
+
183
+ def write(path: Path, content: str) -> None:
184
+ path.parent.mkdir(parents=True, exist_ok=True)
185
+ path.write_text(content, encoding="utf-8")
186
+ created.append(path)
187
+
188
+ # plugin.yaml
189
+ manifest = _build_manifest(cfg)
190
+ write(
191
+ target_dir / "plugin.yaml",
192
+ yaml.dump(manifest, default_flow_style=False, allow_unicode=True, sort_keys=False),
193
+ )
194
+
195
+ # src/__init__.py
196
+ write(target_dir / "src" / "__init__.py", "")
197
+
198
+ # src/main.py
199
+ template = _TEMPLATES[cfg["execution_mode"]]
200
+ write(
201
+ target_dir / "src" / "main.py",
202
+ template.format(name=cfg["name"], description=cfg["description"] or ""),
203
+ )
204
+
205
+ # data/.gitkeep
206
+ write(target_dir / "data" / ".gitkeep", "")
207
+
208
+ # .env (only if env vars defined)
209
+ if cfg.get("env"):
210
+ env_lines = "\n".join(f'{k}={v}' for k, v in cfg["env"].items())
211
+ write(target_dir / ".env", env_lines + "\n")
212
+
213
+ return created
File without changes
xcli/sandbox/cli.py ADDED
@@ -0,0 +1,124 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.markup import escape
9
+ from rich.panel import Panel
10
+ from typer import Typer
11
+
12
+ from xcli._run import ns, run
13
+
14
+ _CTX = {"help_option_names": ["-h", "--help"]}
15
+ app = Typer(help="Sandbox inspection and runtime.", context_settings=_CTX)
16
+ console = Console()
17
+ err = Console(stderr=True)
18
+
19
+
20
+ # ── run ───────────────────────────────────────────────────────
21
+
22
+ @app.command("run")
23
+ def sandbox_run(name: str) -> None:
24
+ """Launch a plugin in an isolated sandbox."""
25
+ from xcore.cli.sandbox_cmd import handle_sandbox
26
+ run(handle_sandbox(ns(subcommand="run", name=name)))
27
+
28
+
29
+ # ── call ──────────────────────────────────────────────────────
30
+
31
+ @app.command("call")
32
+ def sandbox_call(
33
+ name: str,
34
+ action: str,
35
+ payload: str = typer.Option("{}", "--payload", "-p", help="JSON payload"),
36
+ ) -> None:
37
+ """Start a plugin sandbox, call one action, print the result, then stop."""
38
+ try:
39
+ data = json.loads(payload)
40
+ except json.JSONDecodeError as e:
41
+ err.print(f"[red]Invalid JSON payload:[/red] {e}")
42
+ raise typer.Exit(1)
43
+
44
+ async def _run():
45
+ from xcore.configurations.loader import ConfigLoader
46
+ from xcore.kernel.sandbox.process_manager import SandboxConfig, SandboxProcessManager
47
+ from xcore.kernel.security.validation import ManifestValidator
48
+ from xcli._xcore import _NullCtx
49
+
50
+ cfg = ConfigLoader.load(None)
51
+ plugin_dir = Path(cfg.plugins.directory) / name
52
+
53
+ if not plugin_dir.is_dir():
54
+ err.print(f"[red]Plugin '[cyan]{name}[/cyan]' not found in {cfg.plugins.directory}[/red]")
55
+ raise typer.Exit(1)
56
+
57
+ try:
58
+ manifest, _, _ = ManifestValidator().load_and_validate(plugin_dir)
59
+ except Exception as e:
60
+ err.print(f"[red]Invalid manifest:[/red] {escape(str(e))}")
61
+ raise typer.Exit(1)
62
+
63
+ if manifest.execution_mode.value != "sandboxed":
64
+ err.print(
65
+ f"[yellow]⚠[/yellow] Plugin '[cyan]{name}[/cyan]' is mode "
66
+ f"[magenta]{manifest.execution_mode.value}[/magenta], not sandboxed. "
67
+ f"Use [dim]xcli plugin call[/dim] instead."
68
+ )
69
+ raise typer.Exit(1)
70
+
71
+ config = SandboxConfig(
72
+ timeout=manifest.resources.timeout_seconds,
73
+ max_restarts=0,
74
+ startup_timeout=5.0,
75
+ )
76
+ mgr = SandboxProcessManager(manifest, _NullCtx(), config)
77
+
78
+ try:
79
+ with console.status(f"Starting sandbox [cyan]{name}[/cyan]..."):
80
+ await mgr.start()
81
+
82
+ console.print(f"[dim]→ {name}.{action}({payload})[/dim]")
83
+ resp = await mgr._channel.call(action, data)
84
+
85
+ if resp.success:
86
+ console.print(Panel(
87
+ json.dumps(resp.data, indent=2, ensure_ascii=False),
88
+ title=f"[green]{name}.{action}[/green]",
89
+ border_style="green",
90
+ ))
91
+ else:
92
+ console.print(Panel(
93
+ escape(str(resp.data)),
94
+ title=f"[red]{name}.{action} — error[/red]",
95
+ border_style="red",
96
+ ))
97
+ finally:
98
+ await mgr.stop()
99
+ console.print(f"[dim]Sandbox {name} stopped.[/dim]")
100
+
101
+ run(_run())
102
+
103
+
104
+ # ── limits / network / fs ─────────────────────────────────────
105
+
106
+ @app.command("limits")
107
+ def limits(name: str) -> None:
108
+ """Show resource limits declared in the plugin manifest."""
109
+ from xcore.cli.sandbox_cmd import handle_sandbox
110
+ run(handle_sandbox(ns(subcommand="limits", name=name)))
111
+
112
+
113
+ @app.command("network")
114
+ def network(name: str) -> None:
115
+ """Show the network policy for a plugin."""
116
+ from xcore.cli.sandbox_cmd import handle_sandbox
117
+ run(handle_sandbox(ns(subcommand="network", name=name)))
118
+
119
+
120
+ @app.command("fs")
121
+ def fs(name: str) -> None:
122
+ """Show the filesystem policy for a plugin."""
123
+ from xcore.cli.sandbox_cmd import handle_sandbox
124
+ run(handle_sandbox(ns(subcommand="fs", name=name)))
File without changes