meegxer-plugins 0.2.1a2__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,6 @@
1
+ """Plugin runtime and SDKs: Python and Octave plugins as MeegXer strips.
2
+
3
+ Loaded only through the node-provider entry point (CLAUDE.md rule 9).
4
+ """
5
+
6
+ __version__ = "0.0.0"
@@ -0,0 +1,310 @@
1
+ """MeegXer plugin SDK for Python (protocol meegxer.plugin/0.2, spec 0.2.x).
2
+
3
+ A single-file, numpy-only implementation of spec/plugin-protocol.md for use inside
4
+ plugin images. Copy this file into your image and write::
5
+
6
+ import numpy as np
7
+ from meegxer_plugin import Plugin, run
8
+
9
+ class Gain(Plugin):
10
+ manifest = {
11
+ "spec_version": "0.2.1", "name": "lab.gain", "version": "0.1.0",
12
+ "type": "stream", "inputs": [{"name": "in", "type": "signal"}],
13
+ "outputs": [{"name": "out", "type": "signal"}],
14
+ "params": [{"name": "k", "type": "float", "default": 2.0}],
15
+ }
16
+
17
+ def transform(self, data, info, model, state, params):
18
+ return data * params["k"], state or {}, []
19
+
20
+ if __name__ == "__main__":
21
+ run(Gain())
22
+
23
+ ``data`` is a float64 array (n_channels, n_samples) in volts; ``info`` holds
24
+ ``ch_labels``, ``sfreq``, ``stream_id``, ``bads`` plus ``sample_index`` and
25
+ ``timestamp`` of the block. ``state`` is None on the first block. Return
26
+ ``(data, state, events)``; events are dicts with ``sample_index``, ``label`` and
27
+ optionally ``duration`` and ``payload``. A plugin whose output lags its input
28
+ (look-ahead) returns ``(data, state, events, first)`` with ``first`` the sample index
29
+ of its first output sample (output may be shorter than the input), and returns what
30
+ it still holds at the end of the stream from ``flush(info, model, state, params)``.
31
+ Keep everything that must survive between blocks in ``state``: the host owns it,
32
+ saves it and restores it after a restart.
33
+ Never print to stdout (it carries the protocol); log to stderr.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import math
40
+ import struct
41
+ import sys
42
+ import traceback
43
+ from typing import Any, BinaryIO, ClassVar
44
+
45
+ import numpy as np
46
+
47
+ SDK = "meegxer-python/0.1.0"
48
+ SPEC_VERSION = "0.2.5"
49
+ MAGIC = b"MXP1"
50
+ _DTYPES = {"float32", "float64", "int32", "int64", "uint8", "bool"}
51
+
52
+
53
+ class Plugin:
54
+ """Subclass and set ``manifest``; override ``transform`` (and ``fit``)."""
55
+
56
+ manifest: ClassVar[dict[str, Any]] = {}
57
+
58
+ def fit(
59
+ self, data: np.ndarray, info: dict[str, Any], params: dict[str, Any]
60
+ ) -> dict:
61
+ raise NotImplementedError("this plugin does not implement fit")
62
+
63
+ def transform(
64
+ self,
65
+ data: np.ndarray,
66
+ info: dict[str, Any],
67
+ model: dict | None,
68
+ state: dict | None,
69
+ params: dict[str, Any],
70
+ ) -> tuple[Any, ...]:
71
+ raise NotImplementedError
72
+
73
+ def flush(
74
+ self,
75
+ info: dict[str, Any],
76
+ model: dict | None,
77
+ state: dict | None,
78
+ params: dict[str, Any],
79
+ ) -> tuple[Any, ...] | None:
80
+ """End of stream: what a lagging plugin still holds, as transform's
81
+ 4-tuple; None (the default) when nothing is held."""
82
+ return None
83
+
84
+
85
+ # -- wire format ----------------------------------------------------------------
86
+
87
+
88
+ def _read_exact(stream: BinaryIO, n: int) -> bytes:
89
+ buf = b""
90
+ while len(buf) < n:
91
+ chunk = stream.read(n - len(buf))
92
+ if not chunk:
93
+ raise EOFError
94
+ buf += chunk
95
+ return buf
96
+
97
+
98
+ def read_frame(stream: BinaryIO) -> tuple[dict, dict[str, np.ndarray]]:
99
+ magic = stream.read(4)
100
+ if not magic:
101
+ raise EOFError
102
+ magic += _read_exact(stream, 4 - len(magic)) if len(magic) < 4 else b""
103
+ if magic != MAGIC:
104
+ raise ValueError(f"bad magic {magic!r}")
105
+ (hlen,) = struct.unpack("<I", _read_exact(stream, 4))
106
+ header = json.loads(_read_exact(stream, hlen))
107
+ (plen,) = struct.unpack("<Q", _read_exact(stream, 8))
108
+ payload = _read_exact(stream, plen)
109
+ arrays = {}
110
+ for d in header.pop("arrays", []):
111
+ if d["dtype"] not in _DTYPES:
112
+ raise ValueError(f"unknown dtype {d['dtype']}")
113
+ raw = payload[d["offset"] : d["offset"] + d["nbytes"]]
114
+ dtype = np.dtype(d["dtype"]).newbyteorder("<")
115
+ arrays[d["name"]] = np.frombuffer(raw, dtype=dtype).reshape(d["shape"]).copy()
116
+ return header, arrays
117
+
118
+
119
+ def write_frame(stream: BinaryIO, header: dict, arrays: dict[str, np.ndarray]) -> None:
120
+ descs, chunks, offset = [], [], 0
121
+ for name, arr in arrays.items():
122
+ arr = np.asarray(arr)
123
+ dtype = "bool" if arr.dtype == np.bool_ else arr.dtype.name
124
+ if dtype not in _DTYPES:
125
+ raise TypeError(f"array {name!r} has unsupported dtype {arr.dtype}")
126
+ raw = np.ascontiguousarray(
127
+ arr, dtype=np.dtype(dtype).newbyteorder("<")
128
+ ).tobytes()
129
+ descs.append(
130
+ {
131
+ "name": name,
132
+ "dtype": dtype,
133
+ "shape": list(arr.shape),
134
+ "offset": offset,
135
+ "nbytes": len(raw),
136
+ }
137
+ )
138
+ chunks.append(raw)
139
+ offset += len(raw)
140
+ head = json.dumps({**header, "arrays": descs}, allow_nan=False).encode()
141
+ payload = b"".join(chunks)
142
+ stream.write(MAGIC + struct.pack("<I", len(head)) + head)
143
+ stream.write(struct.pack("<Q", len(payload)) + payload)
144
+ stream.flush()
145
+
146
+
147
+ def to_wire(tree: Any, arrays: dict[str, np.ndarray], prefix: str) -> Any:
148
+ if isinstance(tree, np.ndarray):
149
+ name = f"{prefix}{len(arrays)}"
150
+ arrays[name] = tree
151
+ return {"mxarray": name}
152
+ if isinstance(tree, np.generic):
153
+ return to_wire(tree.item(), arrays, prefix)
154
+ if isinstance(tree, float) and not math.isfinite(tree):
155
+ return {
156
+ "mxfloat": "nan" if math.isnan(tree) else ("inf" if tree > 0 else "-inf")
157
+ }
158
+ if isinstance(tree, (list, tuple)):
159
+ return [to_wire(v, arrays, prefix) for v in tree]
160
+ if isinstance(tree, dict):
161
+ return {str(k): to_wire(v, arrays, prefix) for k, v in tree.items()}
162
+ return tree
163
+
164
+
165
+ def from_wire(tree: Any, arrays: dict[str, np.ndarray]) -> Any:
166
+ if isinstance(tree, dict):
167
+ if set(tree) == {"mxarray"}:
168
+ return arrays[tree["mxarray"]]
169
+ if set(tree) == {"mxfloat"}:
170
+ return float(tree["mxfloat"])
171
+ return {k: from_wire(v, arrays) for k, v in tree.items()}
172
+ if isinstance(tree, list):
173
+ return [from_wire(v, arrays) for v in tree]
174
+ return tree
175
+
176
+
177
+ # -- main loop ------------------------------------------------------------------
178
+
179
+
180
+ def run(
181
+ plugin: Plugin, stdin: BinaryIO | None = None, stdout: BinaryIO | None = None
182
+ ) -> None:
183
+ """Serve requests until ``shutdown`` or end of input."""
184
+ inp = stdin or sys.stdin.buffer
185
+ out = stdout or sys.stdout.buffer
186
+ sys.stdout = sys.stderr # stray print() calls must not corrupt the protocol
187
+ models: dict[str, dict] = {}
188
+
189
+ while True:
190
+ try:
191
+ header, arrays = read_frame(inp)
192
+ except EOFError:
193
+ return
194
+ except Exception as exc: # unreadable frame: we cannot resynchronise
195
+ print(f"meegxer_plugin: bad frame: {exc}", file=sys.stderr)
196
+ return
197
+ rid = header.get("id")
198
+ kind = header.get("type")
199
+ try:
200
+ if kind == "hello":
201
+ write_frame(
202
+ out,
203
+ {
204
+ "type": "hello",
205
+ "id": rid,
206
+ "spec_version": SPEC_VERSION,
207
+ "manifest": plugin.manifest,
208
+ "sdk": SDK,
209
+ },
210
+ {},
211
+ )
212
+ elif kind == "shutdown":
213
+ write_frame(out, {"type": "ok", "id": rid}, {})
214
+ return
215
+ elif kind == "load_model":
216
+ models[header["model_id"]] = from_wire(header["model"], arrays)
217
+ write_frame(out, {"type": "ok", "id": rid}, {})
218
+ elif kind == "fit":
219
+ info = dict(
220
+ header["info"],
221
+ sample_index=header.get("sample_index", 0),
222
+ timestamp=header.get("timestamp", 0.0),
223
+ )
224
+ model = plugin.fit(
225
+ arrays["data"].astype(np.float64), info, header["params"]
226
+ )
227
+ model_arrays: dict[str, np.ndarray] = {}
228
+ tree = to_wire(model, model_arrays, "m")
229
+ write_frame(
230
+ out, {"type": "model", "id": rid, "model": tree}, model_arrays
231
+ )
232
+ elif kind in ("transform", "flush"):
233
+ model_id = header.get("model_id")
234
+ if model_id is not None and model_id not in models:
235
+ write_frame(
236
+ out,
237
+ {
238
+ "type": "error",
239
+ "id": rid,
240
+ "error": "unknown_model",
241
+ "message": f"model {model_id} not loaded",
242
+ },
243
+ {},
244
+ )
245
+ continue
246
+ info = dict(header["info"])
247
+ if "sample_index" in header:
248
+ info.update(
249
+ sample_index=header["sample_index"],
250
+ timestamp=header["timestamp"],
251
+ )
252
+ state = header.get("state")
253
+ state = None if state is None else from_wire(state, arrays)
254
+ model = models.get(model_id) if model_id else None
255
+ if kind == "transform":
256
+ got = plugin.transform(
257
+ arrays["data"].astype(np.float64),
258
+ info,
259
+ model,
260
+ state,
261
+ header["params"],
262
+ )
263
+ first = header["sample_index"]
264
+ else:
265
+ got = plugin.flush(info, model, state, header["params"])
266
+ if got is None:
267
+ got = (np.zeros((len(info["ch_labels"]), 0)), state, [], 0)
268
+ first = 0
269
+ data, new_state, events = got[0], got[1], got[2]
270
+ if len(got) > 3:
271
+ first = int(got[3])
272
+ out_arrays: dict[str, np.ndarray] = {
273
+ "data": np.asarray(data, dtype=np.float32)
274
+ }
275
+ tree = to_wire(new_state or {}, out_arrays, "s")
276
+ evs = [{"duration": 0, "payload": {}, **e} for e in events]
277
+ write_frame(
278
+ out,
279
+ {
280
+ "type": "result",
281
+ "id": rid,
282
+ "state": tree,
283
+ "events": evs,
284
+ "sample_index": first,
285
+ },
286
+ out_arrays,
287
+ )
288
+ else:
289
+ write_frame(
290
+ out,
291
+ {
292
+ "type": "error",
293
+ "id": rid,
294
+ "error": "bad_request",
295
+ "message": f"unknown message type {kind!r}",
296
+ },
297
+ {},
298
+ )
299
+ except Exception as exc:
300
+ traceback.print_exc(file=sys.stderr)
301
+ write_frame(
302
+ out,
303
+ {
304
+ "type": "error",
305
+ "id": rid,
306
+ "error": "plugin_error",
307
+ "message": f"{type(exc).__name__}: {exc}",
308
+ },
309
+ {},
310
+ )
@@ -0,0 +1,195 @@
1
+ """Expose a plugin as an ordinary Processor node.
2
+
3
+ The adapter class is created from the plugin's handshake manifest, so the rest of
4
+ MeegXer (graph, schedulers, conformance) cannot tell a plugin from a built-in. Each
5
+ node instance owns one worker; the host keeps model and state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any, ClassVar
12
+
13
+ import numpy as np
14
+
15
+ from meegxer.core.block import Block, Event, SignalInfo
16
+ from meegxer.core.manifest import Manifest
17
+ from meegxer.core.node import Params, Processor, TransformResult
18
+ from meegxer.core.serialization import State, hash_state
19
+ from meegxer.plugins.wire import tree_from_wire, tree_to_wire
20
+ from meegxer.plugins.worker import Launcher, PluginError, PluginWorker
21
+
22
+ RESERVED_PREFIXES = ("meegxer.",)
23
+
24
+
25
+ def _info_dict(info: SignalInfo) -> dict[str, Any]:
26
+ return {
27
+ "ch_labels": list(info.ch_labels),
28
+ "sfreq": info.sfreq,
29
+ "stream_id": info.stream_id,
30
+ "bads": list(info.bads),
31
+ }
32
+
33
+
34
+ class PluginProcessor(Processor):
35
+ """Base class of generated plugin adapters."""
36
+
37
+ launcher: ClassVar[Launcher]
38
+ worker_options: ClassVar[dict[str, Any]] = {}
39
+ origin: ClassVar[dict[str, Any]] = {} # image or command (sessions)
40
+
41
+ @classmethod
42
+ def provenance(cls) -> dict[str, Any]:
43
+ return dict(cls.origin)
44
+
45
+ def __init__(self) -> None:
46
+ self._worker: PluginWorker | None = None
47
+ self._model_ids: dict[int, tuple[State, str]] = {}
48
+
49
+ @property
50
+ def worker(self) -> PluginWorker:
51
+ if self._worker is None:
52
+ self._worker = PluginWorker(self.launcher, **self.worker_options)
53
+ return self._worker
54
+
55
+ def close(self) -> None:
56
+ if self._worker is not None:
57
+ self._worker.close()
58
+ self._worker = None
59
+
60
+ def _model_id(self, model: State) -> str:
61
+ cached = self._model_ids.get(id(model))
62
+ if cached is not None and cached[0] is model:
63
+ return cached[1]
64
+ model_id = hash_state(model)
65
+ self._model_ids = {id(model): (model, model_id)} # keep only the current model
66
+ return model_id
67
+
68
+ def fit(self, calibration: Block, params: Params) -> State:
69
+ reply = self.worker.request(
70
+ {
71
+ "type": "fit",
72
+ "params": dict(params),
73
+ "info": _info_dict(calibration.info),
74
+ "sample_index": calibration.sample_index,
75
+ "timestamp": calibration.timestamp,
76
+ },
77
+ {"data": calibration.data.astype(np.float32)},
78
+ )
79
+ if reply.type != "model":
80
+ raise PluginError(f"expected a model reply, got {reply.type!r}")
81
+ model = tree_from_wire(reply.header.get("model", {}), reply.arrays)
82
+ if not isinstance(model, dict):
83
+ raise PluginError("model must be an object")
84
+ return model
85
+
86
+ def transform(
87
+ self, block: Block, model: State, state: State | None, params: Params
88
+ ) -> TransformResult:
89
+ result = self._exchange("transform", block.info, model, state, params, block)
90
+ assert result is not None
91
+ return result
92
+
93
+ def flush(
94
+ self, info: SignalInfo, model: State, state: State | None, params: Params
95
+ ) -> TransformResult | None:
96
+ """End of stream: ask a lagging plugin for what it still holds. Plugins
97
+ built for protocol 0.1 refuse the message: they hold nothing."""
98
+ try:
99
+ result = self._exchange("flush", info, model, state, params, None)
100
+ except PluginError as exc:
101
+ if "bad_request" in str(exc):
102
+ return None
103
+ raise
104
+ if result is None or not isinstance(result.block, Block):
105
+ return result
106
+ return result if result.block.n_samples else None
107
+
108
+ def _exchange(
109
+ self,
110
+ kind: str,
111
+ info: SignalInfo,
112
+ model: State,
113
+ state: State | None,
114
+ params: Params,
115
+ block: Block | None,
116
+ ) -> TransformResult | None:
117
+ arrays: dict[str, Any] = {}
118
+ if block is not None:
119
+ arrays["data"] = block.data.astype(np.float32)
120
+ state_tree = None if state is None else tree_to_wire(state, arrays, "s")
121
+ models: dict[str, tuple[Any, dict[str, Any]]] = {}
122
+ model_id = None
123
+ if self.manifest.requires_fit:
124
+ model_id = self._model_id(model)
125
+ model_arrays: dict[str, Any] = {}
126
+ models[model_id] = (tree_to_wire(model, model_arrays, "m"), model_arrays)
127
+ header: dict[str, Any] = {
128
+ "type": kind,
129
+ "params": dict(params),
130
+ "info": _info_dict(info),
131
+ "model_id": model_id,
132
+ "state": state_tree,
133
+ }
134
+ if block is not None:
135
+ header.update(sample_index=block.sample_index, timestamp=block.timestamp)
136
+ reply = self.worker.request(header, arrays, models=models)
137
+ if reply.type != "result" or "data" not in reply.arrays:
138
+ raise PluginError(f"expected a result with data, got {reply.header}")
139
+ if isinstance(reply.header.get("info"), dict):
140
+ new = reply.header["info"]
141
+ info = SignalInfo(
142
+ tuple(new["ch_labels"]),
143
+ float(new.get("sfreq", info.sfreq)),
144
+ new.get("stream_id", info.stream_id),
145
+ tuple(new.get("bads", ())),
146
+ )
147
+ data = np.asarray(reply.arrays["data"], dtype=np.float64)
148
+ if data.ndim == 1: # an empty result may lose its channel axis
149
+ data = data.reshape(info.n_channels, -1)
150
+ first = int(reply.header.get("sample_index",
151
+ block.sample_index if block else 0)) # fmt: skip
152
+ if block is not None:
153
+ timestamp = block.timestamp + (first - block.sample_index) / info.sfreq
154
+ else:
155
+ timestamp = 0.0
156
+ out = Block(data, info, first, timestamp)
157
+ new_state = tree_from_wire(reply.header.get("state") or {}, reply.arrays)
158
+ events = tuple(Event(**e) for e in reply.header.get("events", []))
159
+ return TransformResult(out, new_state, events)
160
+
161
+
162
+ def plugin_class(
163
+ manifest: dict[str, Any],
164
+ launcher: Launcher,
165
+ *,
166
+ worker_options: dict[str, Any] | None = None,
167
+ origin: dict[str, Any] | None = None,
168
+ ) -> type[PluginProcessor]:
169
+ """Build the node class for a plugin from its (handshake) manifest."""
170
+ parsed = Manifest.from_dict(manifest)
171
+ if parsed.role != "processor":
172
+ raise PluginError(f"plugins must be processors, not {parsed.role}s")
173
+ if parsed.name.startswith(RESERVED_PREFIXES):
174
+ raise PluginError(f"plugin name {parsed.name!r} uses a reserved prefix")
175
+ attrs: dict[str, Any] = {
176
+ "manifest": parsed,
177
+ "launcher": launcher,
178
+ "worker_options": dict(worker_options or {}),
179
+ "origin": dict(origin or {}),
180
+ "__doc__": f"Plugin {parsed.name} {parsed.version} ({launcher.describe()}).",
181
+ }
182
+ name = "Plugin_" + parsed.name.replace(".", "_")
183
+ return type(name, (PluginProcessor,), attrs)
184
+
185
+
186
+ def probe_manifest(launcher: Launcher, **worker_options: Any) -> dict[str, Any]:
187
+ """Start a plugin once to read its manifest."""
188
+ worker = PluginWorker(launcher, **worker_options)
189
+ try:
190
+ return worker.start()
191
+ finally:
192
+ worker.close()
193
+
194
+
195
+ PluginFactory = Callable[[], type[PluginProcessor]]
meegxer/plugins/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ """``meegxer-plugin``: install, list, remove and check plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from meegxer.core.notices import LOCAL_CODE_NOTICE
11
+ from meegxer.plugins import index
12
+ from meegxer.plugins.conformance import format_report, run_conformance
13
+ from meegxer.plugins.worker import ContainerLauncher, LocalLauncher
14
+
15
+
16
+ def _params(items: list[str]) -> dict[str, object]:
17
+ params: dict[str, object] = {}
18
+ for item in items:
19
+ name, _, value = item.partition("=")
20
+ try:
21
+ params[name] = json.loads(value)
22
+ except ValueError:
23
+ params[name] = value
24
+ return params
25
+
26
+
27
+ def _accepted(args: argparse.Namespace) -> bool:
28
+ """Show the local-code notice; accepted with --accept-risk or a typed yes."""
29
+ print(LOCAL_CODE_NOTICE, file=sys.stderr)
30
+ if args.accept_risk:
31
+ return True
32
+ if not sys.stdin.isatty():
33
+ print("\nRead the notice, then run again with --accept-risk.", file=sys.stderr)
34
+ return False
35
+ answer = input("\nType yes to add this plugin: ")
36
+ return answer.strip().lower() == "yes"
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ parser = argparse.ArgumentParser(prog="meegxer-plugin", description=__doc__)
41
+ sub = parser.add_subparsers(dest="command", required=True)
42
+
43
+ check = sub.add_parser("check", help="run the conformance suite")
44
+ check.add_argument("image", nargs="?", help="container image")
45
+ check.add_argument("--engine", help="docker or podman")
46
+ check.add_argument(
47
+ "--script", type=Path, help="a custom script (process(raw, ...))"
48
+ )
49
+ check.add_argument("--param", action="append", default=[], help="name=value")
50
+ check.add_argument(
51
+ "--local",
52
+ nargs=argparse.REMAINDER,
53
+ help="run a local command instead (unsandboxed)",
54
+ )
55
+
56
+ add = sub.add_parser(
57
+ "add", help="install a plugin: an image, a custom script or a local command"
58
+ )
59
+ add.add_argument("image", nargs="?", help="container image")
60
+ add.add_argument("--engine")
61
+ add.add_argument("--script", type=Path, help="a custom script (process(raw, ...))")
62
+ add.add_argument(
63
+ "--accept-risk",
64
+ action="store_true",
65
+ help="accept the local-code notice (local plugins run unsandboxed)",
66
+ )
67
+ add.add_argument(
68
+ "--local",
69
+ nargs=argparse.REMAINDER,
70
+ help="a local command speaking the plugin protocol (unsandboxed)",
71
+ )
72
+
73
+ sub.add_parser("list", help="list installed plugins")
74
+ rm = sub.add_parser("remove", help="uninstall a plugin by name")
75
+ rm.add_argument("name")
76
+ args = parser.parse_args(argv)
77
+
78
+ if args.command == "check":
79
+ if args.script:
80
+ launcher: ContainerLauncher | LocalLauncher = LocalLauncher(
81
+ tuple(index.script_command(args.script)), allow=True
82
+ )
83
+ elif args.local:
84
+ launcher = LocalLauncher(tuple(args.local), allow=True)
85
+ elif args.image:
86
+ launcher = ContainerLauncher(args.image, args.engine)
87
+ else:
88
+ parser.error("give an image, --script FILE or --local COMMAND")
89
+ checks = run_conformance(launcher, _params(args.param))
90
+ print(format_report(checks))
91
+ return 0 if all(c.passed for c in checks) else 1
92
+ if args.command == "add" and (args.script or args.local):
93
+ if args.script and not args.script.is_file():
94
+ parser.error(f"no such script: {args.script}")
95
+ if not _accepted(args):
96
+ return 1
97
+ command = index.script_command(args.script) if args.script else args.local
98
+ entry = index.add_local(
99
+ command, accept_risk=True, script=args.script if args.script else None
100
+ )
101
+ print(
102
+ f"installed {entry['manifest']['name']} {entry['manifest']['version']} "
103
+ f"(local, unsandboxed) into {index.index_path()}"
104
+ )
105
+ return 0
106
+ if args.command == "add":
107
+ if not args.image:
108
+ parser.error("give an image, --script FILE or --local COMMAND")
109
+ entry = index.add_image(args.image, args.engine)
110
+ print(
111
+ f"installed {entry['manifest']['name']} {entry['manifest']['version']} "
112
+ f"({entry['image_id'][:19]}) into {index.index_path()}"
113
+ )
114
+ return 0
115
+ if args.command == "list":
116
+ for entry in index.load_index():
117
+ where = entry.get("image") or " ".join(entry.get("command", []))
118
+ flag = " local, unsandboxed" if entry.get("unsandboxed") else ""
119
+ name, version = entry["manifest"]["name"], entry["manifest"]["version"]
120
+ print(f"{name:40} {version:8} {where}{flag}")
121
+ return 0
122
+ if args.command == "remove":
123
+ if not index.remove(args.name):
124
+ print(f"no plugin named {args.name}", file=sys.stderr)
125
+ return 1
126
+ print(f"removed {args.name}")
127
+ return 0
128
+ return 2
129
+
130
+
131
+ if __name__ == "__main__":
132
+ sys.exit(main())