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.
- netconverter_cli/__init__.py +3 -0
- netconverter_cli/activity.py +83 -0
- netconverter_cli/api.py +257 -0
- netconverter_cli/backend.py +107 -0
- netconverter_cli/branding.py +79 -0
- netconverter_cli/catalog.py +162 -0
- netconverter_cli/clerk.py +602 -0
- netconverter_cli/cli.py +521 -0
- netconverter_cli/commands.py +63 -0
- netconverter_cli/connect.py +465 -0
- netconverter_cli/controller.py +331 -0
- netconverter_cli/credentials.py +94 -0
- netconverter_cli/guided.py +365 -0
- netconverter_cli/mcp_server.py +640 -0
- netconverter_cli/palette.py +16 -0
- netconverter_cli/presentation.py +254 -0
- netconverter_cli/privacy.py +105 -0
- netconverter_cli/providers.py +246 -0
- netconverter_cli/remote_mcp.py +175 -0
- netconverter_cli/setup.py +179 -0
- netconverter_cli/tui.py +607 -0
- netconverter_cli/update.py +344 -0
- netconverter_cli/workspace.py +148 -0
- netconverter_cli-0.4.0.dist-info/METADATA +167 -0
- netconverter_cli-0.4.0.dist-info/RECORD +28 -0
- netconverter_cli-0.4.0.dist-info/WHEEL +4 -0
- netconverter_cli-0.4.0.dist-info/entry_points.txt +2 -0
- netconverter_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
netconverter_cli/cli.py
ADDED
|
@@ -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")
|