netconverter-cli 0.4.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,521 @@
1
+ """Independent terminal executable; importing it never imports appliance code."""
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from .api import Client, DEFAULT_BASE, APIError
10
+ from .backend import detect_profile, open_client
11
+ from .credentials import get_secret, keyring_available, save_secret
12
+ from .controller import Controller
13
+ from .providers import Provider, ProviderError
14
+ from .commands import execute
15
+ from .setup import configure, load_settings, account
16
+ from .remote_mcp import DEFAULT_MCP_URL
17
+
18
+
19
+ # A Clerk token is the user's NetConverter identity. It is only ever sent to
20
+ # these hosts (over https). A workspace folder is untrusted input — a shared or
21
+ # cloned folder can carry a .netconverter/connection.json — so neither its
22
+ # "server" nor its "auth" may route the token anywhere else. Staging or a
23
+ # self-hosted endpoint is trusted explicitly by the USER's own environment:
24
+ # NETCONVERTER_TRUSTED_HOSTS=host1,host2.
25
+ TRUSTED_CLERK_HOSTS = frozenset({"api.netconverter.ai"})
26
+
27
+
28
+ def trusted_clerk_hosts() -> frozenset:
29
+ extra = {h.strip().lower() for h in os.getenv("NETCONVERTER_TRUSTED_HOSTS", "").split(",") if h.strip()}
30
+ return TRUSTED_CLERK_HOSTS | extra
31
+
32
+
33
+ def clerk_allowed(url: str) -> bool:
34
+ from urllib.parse import urlsplit
35
+
36
+ try:
37
+ parts = urlsplit(url or "")
38
+ except ValueError:
39
+ return False
40
+ return parts.scheme == "https" and (parts.hostname or "").lower() in trusted_clerk_hosts()
41
+
42
+
43
+ def clerk_preferred(settings) -> bool:
44
+ """Clerk as the default credential stays opt-in until the server accepts it.
45
+
46
+ Chosen by the user's environment only — never by the workspace's
47
+ connection.json (``settings``), which a shared folder could carry.
48
+ """
49
+ return os.getenv("NETCONVERTER_AUTH", "key") == "clerk"
50
+
51
+
52
+ def workspace_server_trusted(url: str) -> bool:
53
+ """A server named by a workspace file: a trusted NetConverter host, or loopback for development."""
54
+ from urllib.parse import urlsplit
55
+
56
+ if clerk_allowed(url):
57
+ return True
58
+ try:
59
+ parts = urlsplit(url or "")
60
+ except ValueError:
61
+ return False
62
+ return parts.scheme in {"http", "https"} and (parts.hostname or "") in {"127.0.0.1", "localhost", "::1"}
63
+
64
+
65
+ def mcp_url_for(server: str) -> str:
66
+ base = server.rstrip("/")
67
+ if base.endswith("/external/v1"):
68
+ return base[: -len("/external/v1")] + "/mcp/v1/"
69
+ return DEFAULT_MCP_URL
70
+
71
+
72
+ def _clerk_session():
73
+ from .clerk import ClerkSession
74
+
75
+ session = ClerkSession()
76
+ return session if session.signed_in else None
77
+
78
+
79
+ def rest_credential(server, settings):
80
+ """The credential plain commands use: a stored key, else the sign-in.
81
+
82
+ NETCONVERTER_AUTH=key|clerk (the user's environment, never the workspace's
83
+ connection.json) forces one; unset, a stored key wins and the sign-in is
84
+ used only for a trusted NetConverter host.
85
+ """
86
+ mode = os.getenv("NETCONVERTER_AUTH", "auto")
87
+ if mode == "clerk":
88
+ if not clerk_allowed(server):
89
+ raise SystemExit(f"Refusing to send your NetConverter sign-in to {server}: not a trusted "
90
+ "NetConverter host (set NETCONVERTER_TRUSTED_HOSTS to trust it explicitly)")
91
+ session = _clerk_session()
92
+ if session is None:
93
+ raise SystemExit("Not signed in; run `netconverter login --clerk`")
94
+ return session
95
+ if mode == "key":
96
+ return get_secret(account(server))
97
+ key = get_secret(account(server), prompt=False)
98
+ if key:
99
+ return key
100
+ if clerk_allowed(server):
101
+ session = _clerk_session()
102
+ if session is not None:
103
+ return session
104
+ if not keyring_available():
105
+ return get_secret(account(server)) # no credential store: a memory-only key prompt
106
+ raise SystemExit(f"No NetConverter credential for {server}. Run `netconverter login --clerk` to "
107
+ "sign in with your account, or `netconverter login` to store an API key.")
108
+
109
+
110
+ def clerk_login(args, settings):
111
+ from .clerk import ClerkError, REDIRECT_URI, login, ClerkSession, profile_summary
112
+
113
+ try:
114
+ login(device=args.device, notify=lambda text: print(text, file=sys.stderr))
115
+ try:
116
+ profile = profile_summary(ClerkSession().userinfo())
117
+ except ClerkError:
118
+ profile = {}
119
+ except ClerkError as exc:
120
+ print(str(exc), file=sys.stderr)
121
+ if not args.device:
122
+ print(f"If the browser reported a redirect error, {REDIRECT_URI} must be a "
123
+ "registered redirect URI for this OAuth application.", file=sys.stderr)
124
+ raise SystemExit(1) from None
125
+ print(json.dumps({"signed_in": True, "method": "clerk", **profile,
126
+ "credential_storage": "OS credential store"}))
127
+
128
+
129
+ def logout(args):
130
+ from .clerk import ClerkSession
131
+ from .credentials import delete_secret
132
+
133
+ result = {"clerk_signed_out": ClerkSession().revoke_and_clear()}
134
+ if args.all:
135
+ result["api_key_removed"] = delete_secret(account(args.server))
136
+ print(json.dumps(result))
137
+
138
+
139
+ def whoami(args):
140
+ from .clerk import ClerkError, profile_summary
141
+
142
+ out = {"server": args.server, "api_key_stored": bool(get_secret(account(args.server), prompt=False))}
143
+ session = _clerk_session()
144
+ if session is None:
145
+ out["clerk"] = None
146
+ else:
147
+ try:
148
+ out["clerk"] = {"signed_in": True, **profile_summary(session.userinfo())}
149
+ except ClerkError as exc:
150
+ out["clerk"] = {"signed_in": False, "message": str(exc)}
151
+ print(json.dumps(out, indent=2))
152
+
153
+
154
+ def run_mcp(args):
155
+ from .mcp_server import Bridge, run_stdio
156
+ from .remote_mcp import RemoteMCP
157
+ from . import __version__
158
+
159
+ key = get_secret(account(args.server), prompt=False) if args.auth in ("auto", "key") else ""
160
+ session = _clerk_session() if args.auth in ("auto", "clerk") else None
161
+ key = key or None
162
+ mcp_url = args.mcp_url or mcp_url_for(args.server)
163
+ # The Clerk token goes only to trusted NetConverter hosts, per destination.
164
+ rest_session = session if session is not None and clerk_allowed(args.server) else None
165
+ remote_session = session if session is not None and clerk_allowed(mcp_url) else None
166
+ if session is not None and (rest_session is None or remote_session is None):
167
+ print("netconverter: your sign-in is only sent to trusted NetConverter hosts; "
168
+ "an untrusted server or MCP URL will not receive it", file=sys.stderr)
169
+ if args.auth == "auto":
170
+ # Today: API keys reach the REST API; Clerk tokens reach remote MCP.
171
+ rest, remote_credential = (key or rest_session), (remote_session or key)
172
+ else:
173
+ rest, remote_credential = (key, key) if args.auth == "key" else (rest_session, remote_session)
174
+ remote = None
175
+ if remote_credential is not None and not args.no_remote:
176
+ remote = RemoteMCP(mcp_url, remote_credential, client_version=__version__)
177
+ try:
178
+ run_stdio(Bridge(args.workspace, args.server, rest, remote, strict=getattr(args, "strict", False)))
179
+ finally:
180
+ if remote:
181
+ remote.close()
182
+
183
+
184
+ def remote_for(server, credential):
185
+ """The NetConverter MCP endpoint for questions, only over a trusted host for a sign-in."""
186
+ from .remote_mcp import RemoteMCP
187
+ from . import __version__
188
+
189
+ url = mcp_url_for(server)
190
+ if getattr(credential, "kind", "key") != "key" and not clerk_allowed(url):
191
+ return None
192
+ return RemoteMCP(url, credential, client_version=__version__)
193
+
194
+
195
+ def run_connect(args, ask=input):
196
+ from .connect import CLIENTS, configured_clients, installed_local_clients, plan
197
+
198
+ root = Path(args.workspace).resolve()
199
+ if getattr(args, "all", False):
200
+ targets = [k for k in installed_local_clients() if CLIENTS[k].title not in configured_clients(root)]
201
+ if not targets:
202
+ print("Every AI assistant found on this computer is already connected.")
203
+ return
204
+ print("Connecting: " + ", ".join(CLIENTS[k].title for k in targets) + "\n")
205
+ failed = 0
206
+ for key in targets:
207
+ try:
208
+ _connect_one(argparse.Namespace(**{**vars(args), "client": key, "all": False, "batch": True}),
209
+ root, ask)
210
+ except SystemExit as exc:
211
+ failed += 1 if exc.code else 0
212
+ print()
213
+ print("Restart each assistant to load the netconverter tools.")
214
+ if failed:
215
+ raise SystemExit(1)
216
+ return
217
+ if not args.client:
218
+ done = configured_clients(root)
219
+ found = set(installed_local_clients())
220
+ for key, spec in CLIENTS.items():
221
+ if spec.kind == "remote":
222
+ mark, note = "↗", "remote connector (setup steps)"
223
+ elif spec.title in done:
224
+ mark, note = "●", "connected"
225
+ else:
226
+ mark, note = "○", "found on this computer" if key in found else ""
227
+ print(f" {mark} {key:<15} {spec.title:<28} {note}".rstrip())
228
+ print("\nnetconverter connect <client> shows the change, then asks before applying it")
229
+ print("netconverter connect --all connects every assistant found on this computer")
230
+ return
231
+ _connect_one(args, root, ask)
232
+
233
+
234
+ def _connect_one(args, root, ask):
235
+ from .connect import CLIENTS, plan
236
+
237
+ change = plan(args.client, root)
238
+ print(f"{change.title}: {change.summary}\n\n{change.preview}\n")
239
+ if args.print_only:
240
+ for extra in change.followups:
241
+ print(f"{extra.title} (offered after applying): {extra.summary}\n\n{extra.preview}\n")
242
+ return
243
+ if not change.can_apply:
244
+ print(change.manual)
245
+ if CLIENTS.get(args.client) and CLIENTS[args.client].kind == "remote":
246
+ return # setup steps, not a failure
247
+ raise SystemExit(1)
248
+ if not args.yes and ask(f"Apply to {change.where}? [y/N] ").strip().lower() not in {"y", "yes"}:
249
+ print("Nothing was changed.")
250
+ return
251
+ try:
252
+ print(change.apply())
253
+ except Exception as exc:
254
+ print(f"Could not apply: {exc}", file=sys.stderr)
255
+ raise SystemExit(1) from None
256
+ for extra in change.followups:
257
+ print(f"\n{extra.title}: {extra.summary}\n\n{extra.preview}\n")
258
+ if not extra.can_apply:
259
+ print(extra.manual)
260
+ continue
261
+ if getattr(args, "batch", False): # connect --all: optional extras need their own run
262
+ print(f"Skipped in --all; run `netconverter connect {args.client}` to review it.")
263
+ continue
264
+ if not args.yes:
265
+ hint = "[Y/n]" if extra.default_yes else "[y/N]"
266
+ answer = ask(f"Apply to {extra.where}? {hint} ").strip().lower()
267
+ if answer not in {"y", "yes"} and not (extra.default_yes and answer == ""):
268
+ print("Skipped; nothing else was changed.")
269
+ continue
270
+ try:
271
+ print(extra.apply())
272
+ except Exception as exc:
273
+ print(f"Could not apply: {exc}", file=sys.stderr)
274
+ raise SystemExit(1) from None
275
+
276
+
277
+ # Account commands work from anywhere; everything else works on a workspace folder.
278
+ ACCOUNT_COMMANDS = frozenset({"login", "logout", "whoami", "update"})
279
+ # No passive update notice: the MCP server's stdout is the protocol stream, and
280
+ # `update` reports versions itself.
281
+ NO_UPDATE_NOTICE = frozenset({"mcp", "update"})
282
+
283
+
284
+ def workspace_settings(args) -> dict:
285
+ """Saved folder settings; a missing folder is a clear message, never a traceback.
286
+
287
+ A shell can sit in a folder that was deleted underneath it (a removed git
288
+ worktree), where even resolving "." fails.
289
+ """
290
+ try:
291
+ return load_settings(args.workspace)
292
+ except FileNotFoundError:
293
+ if args.command in ACCOUNT_COMMANDS:
294
+ return {}
295
+ raise SystemExit(
296
+ f"netconverter: the folder {args.workspace!r} no longer exists (was it deleted?). "
297
+ "cd to your configuration folder and run the command again."
298
+ ) from None
299
+
300
+
301
+ def parser():
302
+ from . import __version__
303
+
304
+ p = argparse.ArgumentParser(prog="netconverter")
305
+ p.add_argument("--version", action="version", version=f"netconverter-cli {__version__}")
306
+ p.add_argument("--workspace", default=".")
307
+ p.add_argument("--server", default=os.getenv("NETCONVERTER_URL"))
308
+ p.add_argument(
309
+ "--provider",
310
+ choices=["openai", "anthropic", "gemini", "ollama"],
311
+ default=os.getenv("NETCONVERTER_PROVIDER"),
312
+ )
313
+ p.add_argument("--model", default=os.getenv("NETCONVERTER_MODEL"))
314
+ subs = p.add_subparsers(dest="command")
315
+ subs.add_parser("run")
316
+ login = subs.add_parser("login", help="store an API key, or sign in with --clerk")
317
+ method = login.add_mutually_exclusive_group()
318
+ method.add_argument("--clerk", action="store_true", help="sign in with your NetConverter account (browser)")
319
+ method.add_argument("--key", action="store_true", help="store an API key (the default)")
320
+ login.add_argument("--device", action="store_true", help="Clerk sign-in with a device code (headless machines)")
321
+ logout = subs.add_parser("logout", help="remove the Clerk sign-in from the OS credential store")
322
+ logout.add_argument("--all", action="store_true", help="also remove the stored API key for this server")
323
+ subs.add_parser("whoami", help="show the signed-in account (never prints tokens)")
324
+ mcp = subs.add_parser("mcp", help="run the local stdio MCP server for AI clients")
325
+ mcp.add_argument("--auth", choices=["auto", "clerk", "key"], default=os.getenv("NETCONVERTER_MCP_AUTH", "auto"))
326
+ mcp.add_argument("--mcp-url", default=os.getenv("NETCONVERTER_MCP_URL"))
327
+ mcp.add_argument("--no-remote", action="store_true", help="serve only the local file tools")
328
+ mcp.add_argument("--strict", action="store_true", default=os.getenv("NETCONVERTER_MCP_STRICT") == "1",
329
+ help="proxy only the strict-view remote tools (summaries, names as tokens)")
330
+ mcp.add_argument("--workspace", default=argparse.SUPPRESS, help="folder the file tools may read and write")
331
+ from .connect import CLIENTS as _AI_CLIENTS
332
+ connect = subs.add_parser(
333
+ "connect", help="wire an AI assistant (Claude, Cursor, Gemini, ChatGPT, …) to this folder")
334
+ connect.add_argument("client", nargs="?", choices=list(_AI_CLIENTS))
335
+ connect.add_argument("--all", action="store_true", help="connect every AI assistant found on this computer")
336
+ connect.add_argument("--print", action="store_true", dest="print_only", help="show the change without applying it")
337
+ connect.add_argument("--yes", action="store_true", help="apply without asking (scripts)")
338
+ update = subs.add_parser("update", help="upgrade from PyPI, or install the latest verified release from GitHub")
339
+ update.add_argument("--check", action="store_true", help="only report whether a newer release exists")
340
+ update.add_argument("--yes", action="store_true", help="install without asking")
341
+ subs.add_parser("capabilities", help="vendors, conversion paths and limits for your credential")
342
+ subs.add_parser("setup")
343
+ subs.add_parser("jobs")
344
+ resume = subs.add_parser("resume")
345
+ resume.add_argument("job_id", nargs="?")
346
+ for name in ("validate", "analyze", "optimize", "convert"):
347
+ sub = subs.add_parser(name)
348
+ sub.add_argument("file")
349
+ sub.add_argument("--vendor", required=True, help="source vendor code (see `netconverter capabilities`)")
350
+ sub.add_argument("--to", dest="target", help="target vendor code (convert)")
351
+ sub.add_argument("--options", default="{}")
352
+ ask = subs.add_parser("ask")
353
+ ask.add_argument("question")
354
+ query = subs.add_parser("query")
355
+ query.add_argument("operation")
356
+ query.add_argument("--arguments", default="{}")
357
+ subs.add_parser("download")
358
+ subs.add_parser("allowance")
359
+ subs.add_parser("model-test")
360
+ plain = subs.add_parser("plain")
361
+ plain.add_argument("instruction", nargs="?")
362
+ return p
363
+
364
+
365
+ def main(argv=None):
366
+ args = parser().parse_args(argv)
367
+ if args.command == "update":
368
+ from . import update
369
+
370
+ raise SystemExit(update.run(args))
371
+ if args.command not in NO_UPDATE_NOTICE:
372
+ from . import update
373
+
374
+ update.notify() # stderr, once a day, never blocks or fails the command
375
+ settings = workspace_settings(args)
376
+ saved_server = settings.get("server")
377
+ if not args.server and saved_server and not workspace_server_trusted(saved_server):
378
+ # connection.json travels with a shared or cloned folder: it must not be able
379
+ # to point any credential (an API key, NETCONVERTER_API_KEY) at a foreign host.
380
+ raise SystemExit(
381
+ f"netconverter: this folder's saved server {saved_server!r} is not a trusted NetConverter "
382
+ "host. Pass --server explicitly, or set NETCONVERTER_TRUSTED_HOSTS to trust it."
383
+ )
384
+ args.server = args.server or saved_server or DEFAULT_BASE
385
+ args.provider = args.provider or settings.get("provider")
386
+ args.model = args.model or settings.get("model")
387
+ if args.command == "login" and args.key and args.device:
388
+ raise SystemExit("--device applies to Clerk sign-in; omit --key")
389
+ if args.command == "login" and (args.clerk or args.device or (not args.key and clerk_preferred(settings))):
390
+ clerk_login(args, settings)
391
+ return
392
+ if args.command == "logout":
393
+ logout(args)
394
+ return
395
+ if args.command == "whoami":
396
+ whoami(args)
397
+ return
398
+ if args.command == "mcp":
399
+ run_mcp(args)
400
+ return
401
+ if args.command == "connect":
402
+ run_connect(args)
403
+ return
404
+ if args.command == "login":
405
+ key = getpass.getpass("NetConverter API key (hidden): ").strip()
406
+ kind, _ = detect_profile(key, args.server)
407
+ stored = save_secret(account(args.server), key)
408
+ from .workspace import Workspace
409
+ if Path(args.workspace).is_dir(): # remembering the server is optional
410
+ workspace = Workspace(args.workspace)
411
+ workspace._inside(workspace.state_dir).mkdir(mode=0o700, exist_ok=True)
412
+ workspace._atomic_replace(workspace.state_dir / "connection.json", json.dumps({**settings, "server": args.server}).encode())
413
+ print(
414
+ json.dumps(
415
+ {
416
+ "profile": "full_api" if kind == "external" else "terminal_quick",
417
+ "credential_storage": "OS credential store"
418
+ if stored
419
+ else "unavailable; enter key for each session",
420
+ }
421
+ )
422
+ )
423
+ return
424
+ provider = None
425
+ client = None
426
+ try:
427
+ if args.command == "setup" or (args.command in (None, "run") and not settings):
428
+ client, provider = configure(args.workspace, {"server": args.server, "provider": args.provider, "model": args.model})
429
+ from .tui import Terminal
430
+ Terminal(Controller(Path(args.workspace), client, provider,
431
+ remote=lambda: remote_for(args.server, client.credential))).run(mouse=False)
432
+ return
433
+ if args.provider:
434
+ provider = Provider(
435
+ args.provider,
436
+ args.model,
437
+ "" if args.provider == "ollama" else get_secret(args.provider),
438
+ )
439
+ if args.command == "model-test":
440
+ if not provider:
441
+ raise ValueError("Choose --provider and --model")
442
+ result = provider.select("Find unused objects")
443
+ print(
444
+ json.dumps(
445
+ {
446
+ "provider": provider.name,
447
+ "model": provider.model,
448
+ "compatible": result == "unused_objects",
449
+ }
450
+ )
451
+ )
452
+ return
453
+ credential = rest_credential(args.server, settings)
454
+ client, kind = open_client(credential, args.server)
455
+ c = Controller(Path(args.workspace), client, provider, kind=kind,
456
+ remote=lambda: remote_for(args.server, credential))
457
+ if args.command in (None, "run"):
458
+ from .tui import Terminal
459
+
460
+ Terminal(c).run(mouse=False)
461
+ return
462
+ if args.command == "capabilities":
463
+ result = c.capabilities()
464
+ elif args.command in {"validate", "analyze", "optimize", "convert"}:
465
+ c.capabilities()
466
+ c.select(args.file, args.vendor)
467
+ options = json.loads(args.options)
468
+ if args.target:
469
+ options["target_vendor"] = args.target
470
+ result = c.submit("validate" if args.command == "analyze" else args.command, options)
471
+ elif args.command == "resume":
472
+ result = c.resume(args.job_id)
473
+ elif args.command == "jobs":
474
+ result = client.jobs()
475
+ elif args.command == "query":
476
+ result = c.query(args.operation, json.loads(args.arguments))
477
+ elif args.command == "ask":
478
+ result = execute(c, args.question)
479
+ elif args.command == "download":
480
+ result = c.download()
481
+ elif args.command == "allowance":
482
+ result = c.capabilities()
483
+ elif args.command == "plain":
484
+ if args.instruction:
485
+ result = execute(c, args.instruction)
486
+ else:
487
+ while True:
488
+ try:
489
+ line = input("netconverter> ")
490
+ except EOFError:
491
+ break
492
+ if line in {"/quit", "/exit"}:
493
+ break
494
+ try:
495
+ print(json.dumps(execute(c, line), indent=2, default=str))
496
+ except Exception:
497
+ print(
498
+ "Command failed; use /help. No diagnostic payload was saved."
499
+ )
500
+ return
501
+ print(json.dumps(result, indent=2, default=str))
502
+ except ProviderError as exc:
503
+ print(str(exc))
504
+ raise SystemExit(1) from None
505
+ except APIError as exc:
506
+ print(str(exc))
507
+ if exc.detail:
508
+ print(json.dumps(exc.detail))
509
+ raise SystemExit(1) from None
510
+ except (ValueError, RuntimeError):
511
+ print("Invalid or unavailable operation; use --help or guided plain mode.")
512
+ raise SystemExit(1) from None
513
+ finally:
514
+ if client:
515
+ client.close()
516
+ if provider:
517
+ provider.close()
518
+
519
+
520
+ if __name__ == "__main__":
521
+ main()
@@ -0,0 +1,63 @@
1
+ """Small guided command language; no eval, subprocess, shell, or remote URLs."""
2
+
3
+ import json
4
+ import shlex
5
+
6
+ HELP = """/open "file.cfg" VENDOR_CODE (vendor codes: netconverter --json capabilities)
7
+ /convert {"target_vendor":"palo_alto_xml"} /validate /optimize
8
+ /jobs /resume [job_id] /status /target /download /allowance
9
+ /unused_objects /routes /interfaces /policy /nat
10
+ /dependencies {"object_name":"NAME"}
11
+ /flow {"source_ip":"192.0.2.1","destination_ip":"198.51.100.1","protocol":"tcp","destination_port":80}
12
+ Questions use the configured model to select a permitted operation. Sensitive parameters are entered through commands and go directly to NetConverter."""
13
+
14
+
15
+ def execute(controller, text):
16
+ if not text.startswith("/"):
17
+ op = controller.interpret(text)
18
+ if op in {"convert", "flow", "dependencies"}:
19
+ return {
20
+ "requires_input": op,
21
+ "instructions": "Use /"
22
+ + op
23
+ + " with explicit parameters. /help shows the format.",
24
+ }
25
+ text = "/" + op
26
+ command, _, rest = text[1:].partition(" ")
27
+ rest = rest.strip()
28
+ if command == "help":
29
+ return {"help": HELP}
30
+ if command == "open":
31
+ parts = shlex.split(rest)
32
+ if len(parts) != 2:
33
+ raise ValueError('Use /open "filename" vendor')
34
+ return controller.select(*parts)
35
+ if command in {"analyze", "validate", "optimize", "convert"}:
36
+ options = json.loads(rest) if rest else {}
37
+ if not isinstance(options, dict):
38
+ raise ValueError("Options must be a JSON object")
39
+ return controller.submit(command, options)
40
+ if command in {"status", "resume"}:
41
+ return controller.resume(rest or None)
42
+ if command == "jobs":
43
+ return controller.client.jobs()
44
+ if command in {"artifacts", "download"}:
45
+ return controller.download()
46
+ if command == "allowance":
47
+ return controller.capabilities()
48
+ if command == "target":
49
+ return controller.target()
50
+ if command in {
51
+ "unused_objects",
52
+ "routes",
53
+ "interfaces",
54
+ "policy",
55
+ "nat",
56
+ "dependencies",
57
+ "flow",
58
+ }:
59
+ args = json.loads(rest) if rest else {}
60
+ if not isinstance(args, dict):
61
+ raise ValueError("Arguments must be a JSON object")
62
+ return controller.query(command, args)
63
+ raise ValueError("Unsupported command; use /help")