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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""What the local MCP bridge did, as metadata the full-screen app can show.
|
|
2
|
+
|
|
3
|
+
One JSON line per bridge tool call in ``.netconverter/bridge.jsonl``: time,
|
|
4
|
+
AI client name, tool, workspace-relative path, sha256, byte count, job id,
|
|
5
|
+
job type, vendors and status. Configuration text, tool results and
|
|
6
|
+
credentials are never written: only the fields in ``FIELDS`` survive, and
|
|
7
|
+
each must be a short scalar.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
LOG_NAME = "bridge.jsonl"
|
|
19
|
+
FIELDS = ("ts", "client", "tool", "path", "sha256", "bytes", "files", "job_id",
|
|
20
|
+
"job_type", "source_vendor", "target_vendor", "status", "error")
|
|
21
|
+
MAX_BYTES = 512 * 1024
|
|
22
|
+
KEEP_LINES = 500
|
|
23
|
+
_TEXT = re.compile(r"[\w .:/@+()-]{0,200}\Z")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _clean(entry: dict) -> dict:
|
|
27
|
+
out = {}
|
|
28
|
+
for key in FIELDS:
|
|
29
|
+
value = entry.get(key)
|
|
30
|
+
if isinstance(value, bool) or value is None:
|
|
31
|
+
continue
|
|
32
|
+
if isinstance(value, (int, float)) and abs(value) < 1e13:
|
|
33
|
+
out[key] = value
|
|
34
|
+
elif isinstance(value, str) and _TEXT.fullmatch(value):
|
|
35
|
+
out[key] = value
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def record(state_dir: Path, **entry) -> None:
|
|
40
|
+
"""Append one metadata line; never raises (the bridge must keep serving)."""
|
|
41
|
+
try:
|
|
42
|
+
state_dir.mkdir(mode=0o700, exist_ok=True)
|
|
43
|
+
path = state_dir / LOG_NAME
|
|
44
|
+
if path.is_symlink():
|
|
45
|
+
return
|
|
46
|
+
line = json.dumps(_clean({"ts": round(time.time(), 3), **entry}), sort_keys=True) + "\n"
|
|
47
|
+
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
48
|
+
with os.fdopen(fd, "a", encoding="utf-8") as stream:
|
|
49
|
+
stream.write(line)
|
|
50
|
+
if path.stat().st_size > MAX_BYTES:
|
|
51
|
+
kept = path.read_text(encoding="utf-8").splitlines()[-KEEP_LINES:]
|
|
52
|
+
tmp = path.with_suffix(".tmp")
|
|
53
|
+
tmp.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
|
54
|
+
os.replace(tmp, path)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def recent(state_dir: Path, limit: int = 20) -> list[dict]:
|
|
60
|
+
"""Newest-last entries; malformed lines are skipped."""
|
|
61
|
+
path = state_dir / LOG_NAME
|
|
62
|
+
try:
|
|
63
|
+
if path.is_symlink() or not path.is_file():
|
|
64
|
+
return []
|
|
65
|
+
lines = path.read_text(encoding="utf-8").splitlines()[-max(limit, 1):]
|
|
66
|
+
except OSError:
|
|
67
|
+
return []
|
|
68
|
+
out = []
|
|
69
|
+
for line in lines:
|
|
70
|
+
try:
|
|
71
|
+
item = json.loads(line)
|
|
72
|
+
except ValueError:
|
|
73
|
+
continue
|
|
74
|
+
if isinstance(item, dict):
|
|
75
|
+
out.append(_clean(item))
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def mtime(state_dir: Path) -> float:
|
|
80
|
+
try:
|
|
81
|
+
return (state_dir / LOG_NAME).stat().st_mtime
|
|
82
|
+
except OSError:
|
|
83
|
+
return 0.0
|
netconverter_cli/api.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Direct NetConverter data channel. No model SDK imports belong here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE = "https://api.netconverter.ai/external/v1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIError(RuntimeError):
|
|
15
|
+
def __init__(self, status: int, code: str, request_id: str = "", detail=None):
|
|
16
|
+
super().__init__(
|
|
17
|
+
f"NetConverter {code} (HTTP {status}, request {request_id or 'unavailable'})"
|
|
18
|
+
)
|
|
19
|
+
self.status, self.code, self.request_id, self.detail = (
|
|
20
|
+
status,
|
|
21
|
+
code,
|
|
22
|
+
request_id,
|
|
23
|
+
detail,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_url(base: str) -> str:
|
|
28
|
+
"""HTTPS only (HTTP for explicit loopback testing); no userinfo/query/fragment."""
|
|
29
|
+
parts = urlsplit(base)
|
|
30
|
+
if parts.username or parts.password or parts.query or parts.fragment:
|
|
31
|
+
raise ValueError("Invalid server URL")
|
|
32
|
+
if parts.scheme != "https" and not (
|
|
33
|
+
parts.scheme == "http" and parts.hostname in {"127.0.0.1", "localhost", "::1"}
|
|
34
|
+
):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"Server connections require HTTPS (HTTP only for explicit loopback testing)"
|
|
37
|
+
)
|
|
38
|
+
return base
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StaticCredential:
|
|
42
|
+
"""An API key (``nck_*`` or terminal_quick). It cannot be refreshed."""
|
|
43
|
+
|
|
44
|
+
kind = "key"
|
|
45
|
+
|
|
46
|
+
def __init__(self, key: str):
|
|
47
|
+
self._key = key
|
|
48
|
+
|
|
49
|
+
def token(self) -> str:
|
|
50
|
+
return self._key
|
|
51
|
+
|
|
52
|
+
def refresh(self) -> bool:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BearerAuth(httpx.Auth):
|
|
57
|
+
"""Bearer from a credential; one refresh-and-retry on HTTP 401.
|
|
58
|
+
|
|
59
|
+
Works for static keys (no retry) and for Clerk OAuth sessions, whose
|
|
60
|
+
``token()`` refreshes an expiring access token and whose ``refresh()``
|
|
61
|
+
forces a refresh after the server rejected the current one.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
requires_request_body = True
|
|
65
|
+
|
|
66
|
+
def __init__(self, credential):
|
|
67
|
+
self.credential = credential
|
|
68
|
+
|
|
69
|
+
def auth_flow(self, request):
|
|
70
|
+
request.headers["Authorization"] = f"Bearer {self.credential.token()}"
|
|
71
|
+
response = yield request
|
|
72
|
+
if response.status_code == 401 and self.credential.refresh():
|
|
73
|
+
request.headers["Authorization"] = f"Bearer {self.credential.token()}"
|
|
74
|
+
yield request
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def as_credential(key):
|
|
78
|
+
return StaticCredential(key) if isinstance(key, str) else key
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Client:
|
|
82
|
+
# Path allowlist for this client's restricted surface.
|
|
83
|
+
_ALLOWED = re.compile(r"quick/[A-Za-z0-9_/-]{1,200}\Z")
|
|
84
|
+
|
|
85
|
+
def __init__(self, key, base: str = DEFAULT_BASE, *, transport=None):
|
|
86
|
+
check_url(base)
|
|
87
|
+
self.credential = as_credential(key)
|
|
88
|
+
self.http = httpx.Client(
|
|
89
|
+
base_url=base.rstrip("/") + "/",
|
|
90
|
+
timeout=60,
|
|
91
|
+
auth=BearerAuth(self.credential),
|
|
92
|
+
transport=transport,
|
|
93
|
+
follow_redirects=False,
|
|
94
|
+
trust_env=False,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def close(self):
|
|
98
|
+
self.http.close()
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def identifier(value: str) -> str:
|
|
102
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", value):
|
|
103
|
+
raise ValueError("Invalid resource identifier")
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
def _send(self, method, path, idempotency_key=None, **kwargs):
|
|
107
|
+
if not self._ALLOWED.fullmatch(path) or ".." in path:
|
|
108
|
+
raise ValueError("Only the restricted NetConverter API may be called")
|
|
109
|
+
headers = {"X-Request-ID": "req_" + uuid.uuid4().hex}
|
|
110
|
+
if idempotency_key:
|
|
111
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
112
|
+
try:
|
|
113
|
+
response = self.http.request(method, path, headers=headers, **kwargs)
|
|
114
|
+
except httpx.HTTPError:
|
|
115
|
+
raise APIError(0, "connection_failed", headers["X-Request-ID"]) from None
|
|
116
|
+
return response, headers["X-Request-ID"]
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _error(response, request_id):
|
|
120
|
+
try:
|
|
121
|
+
body = response.json()
|
|
122
|
+
except ValueError:
|
|
123
|
+
return APIError(response.status_code, "invalid_response", request_id)
|
|
124
|
+
error = body.get("error", {}) if isinstance(body, dict) else {}
|
|
125
|
+
if not isinstance(error, dict):
|
|
126
|
+
error = {}
|
|
127
|
+
return APIError(
|
|
128
|
+
response.status_code,
|
|
129
|
+
str(error.get("code", "request_failed")),
|
|
130
|
+
body.get("request_id", request_id) if isinstance(body, dict) else request_id,
|
|
131
|
+
body.get("detail", error.get("detail")) if isinstance(body, dict) else None,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def request(
|
|
135
|
+
self, method: str, path: str, *, idempotency_key: str | None = None, **kwargs
|
|
136
|
+
):
|
|
137
|
+
response, request_id = self._send(method, path, idempotency_key, **kwargs)
|
|
138
|
+
try:
|
|
139
|
+
body = response.json()
|
|
140
|
+
except ValueError:
|
|
141
|
+
raise APIError(response.status_code, "invalid_response", request_id) from None
|
|
142
|
+
if response.is_error or not isinstance(body, dict) or body.get("success") is not True:
|
|
143
|
+
raise self._error(response, request_id)
|
|
144
|
+
return body["data"]
|
|
145
|
+
|
|
146
|
+
def capabilities(self):
|
|
147
|
+
return self.request("GET", "quick/capabilities")
|
|
148
|
+
|
|
149
|
+
def upload(self, content: bytes, vendor: str):
|
|
150
|
+
return self.request(
|
|
151
|
+
"POST", "quick/configurations", content=content, params={"vendor": vendor}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def submit(
|
|
155
|
+
self, configuration_id: str, operation: str, options: dict, idempotency_key: str
|
|
156
|
+
):
|
|
157
|
+
return self.request(
|
|
158
|
+
"POST",
|
|
159
|
+
"quick/jobs",
|
|
160
|
+
idempotency_key=idempotency_key,
|
|
161
|
+
json={
|
|
162
|
+
"configuration_id": configuration_id,
|
|
163
|
+
"operation": operation,
|
|
164
|
+
"options": options,
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def jobs(self):
|
|
169
|
+
return self.request("GET", "quick/jobs")
|
|
170
|
+
|
|
171
|
+
def status(self, job_id: str):
|
|
172
|
+
return self.request("GET", f"quick/jobs/{self.identifier(job_id)}")
|
|
173
|
+
|
|
174
|
+
def query(
|
|
175
|
+
self,
|
|
176
|
+
job_id: str,
|
|
177
|
+
operation: str,
|
|
178
|
+
arguments: dict,
|
|
179
|
+
idempotency_key: str,
|
|
180
|
+
offset: int = 0,
|
|
181
|
+
):
|
|
182
|
+
return self.request(
|
|
183
|
+
"POST",
|
|
184
|
+
"quick/queries",
|
|
185
|
+
idempotency_key=idempotency_key,
|
|
186
|
+
json={
|
|
187
|
+
"job_id": job_id,
|
|
188
|
+
"operation": operation,
|
|
189
|
+
"arguments": arguments,
|
|
190
|
+
"offset": offset,
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def target(self, job_id: str):
|
|
195
|
+
return self.request("POST", f"quick/jobs/{self.identifier(job_id)}/target")
|
|
196
|
+
|
|
197
|
+
def artifacts(self, job_id: str):
|
|
198
|
+
return self.request("GET", f"quick/jobs/{self.identifier(job_id)}/artifacts")
|
|
199
|
+
|
|
200
|
+
def download(self, job_id: str, artifact_id: str):
|
|
201
|
+
return self.request(
|
|
202
|
+
"GET",
|
|
203
|
+
f"quick/jobs/{self.identifier(job_id)}/artifacts/{self.identifier(artifact_id)}",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ExternalClient(Client):
|
|
208
|
+
"""Full External API v1 (``/jobs``, ``/me``, ``/capabilities``), same Bearer handling.
|
|
209
|
+
|
|
210
|
+
Accepts ``nck_*`` keys and Clerk OAuth access tokens.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
_ALLOWED = re.compile(
|
|
214
|
+
r"(?:me|capabilities|jobs|jobs/[A-Za-z0-9_-]{1,80}(?:/translated|/report|/evidence\.xlsx)?)\Z"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def me(self):
|
|
218
|
+
return self.request("GET", "me")
|
|
219
|
+
|
|
220
|
+
def capabilities(self):
|
|
221
|
+
"""Vendors, source→target paths, job types and limits POST /jobs accepts."""
|
|
222
|
+
return self.request("GET", "capabilities")
|
|
223
|
+
|
|
224
|
+
def jobs(self, limit: int = 25):
|
|
225
|
+
data = self.request("GET", "jobs", params={"comparable": "false", "limit": limit})
|
|
226
|
+
return data.get("jobs", []) if isinstance(data, dict) else []
|
|
227
|
+
|
|
228
|
+
def evidence_workbook(self, job_id: str) -> bytes | None:
|
|
229
|
+
"""The customer evidence workbook; None when this job has none."""
|
|
230
|
+
response, request_id = self._send(
|
|
231
|
+
"GET", f"jobs/{self.identifier(job_id)}/evidence.xlsx"
|
|
232
|
+
)
|
|
233
|
+
if response.status_code in (404, 409, 422):
|
|
234
|
+
return None
|
|
235
|
+
if response.is_error:
|
|
236
|
+
raise self._error(response, request_id)
|
|
237
|
+
return response.content
|
|
238
|
+
|
|
239
|
+
def submit_job(self, body: dict, idempotency_key: str):
|
|
240
|
+
return self.request("POST", "jobs", idempotency_key=idempotency_key, json=body)
|
|
241
|
+
|
|
242
|
+
def job(self, job_id: str):
|
|
243
|
+
return self.request("GET", f"jobs/{self.identifier(job_id)}")
|
|
244
|
+
|
|
245
|
+
def report(self, job_id: str):
|
|
246
|
+
return self.request("GET", f"jobs/{self.identifier(job_id)}/report")
|
|
247
|
+
|
|
248
|
+
def translated(self, job_id: str) -> bytes | None:
|
|
249
|
+
"""Translated configuration bytes; None while absent (queued/blocked)."""
|
|
250
|
+
response, request_id = self._send(
|
|
251
|
+
"GET", f"jobs/{self.identifier(job_id)}/translated"
|
|
252
|
+
)
|
|
253
|
+
if response.status_code == 404:
|
|
254
|
+
return None
|
|
255
|
+
if response.is_error:
|
|
256
|
+
raise self._error(response, request_id)
|
|
257
|
+
return response.content
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Which NetConverter API a credential reaches, and the full-API job path.
|
|
2
|
+
|
|
3
|
+
Shared by the full-screen app and the local MCP bridge so the two can never
|
|
4
|
+
disagree about what a credential may do. ``external``: an ``nck_*`` key or a
|
|
5
|
+
Clerk sign-in on the full External API (``/jobs``, any supported vendor).
|
|
6
|
+
``quick``: a 0.1.x Terminal Quick pilot key, whose scopes never reach
|
|
7
|
+
``/jobs`` (the server answers 403), limited to ASA → Palo Alto.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from .api import APIError, Client, ExternalClient
|
|
16
|
+
from .catalog import JOB_TYPES, device_type_of
|
|
17
|
+
|
|
18
|
+
# Full External API inline-submit ceiling (server: 5 MB).
|
|
19
|
+
EXTERNAL_MAX_BYTES = 5 * 1024 * 1024
|
|
20
|
+
QUICK_JOB_TYPES = frozenset({"convert", "optimize"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def detect_profile(credential, server, *, transport=None) -> tuple[str, frozenset]:
|
|
24
|
+
"""('external', job types /me advertises) or ('quick', pilot job types).
|
|
25
|
+
|
|
26
|
+
Raises APIError when neither surface accepts the credential.
|
|
27
|
+
"""
|
|
28
|
+
client = ExternalClient(credential, server, transport=transport)
|
|
29
|
+
try:
|
|
30
|
+
try:
|
|
31
|
+
me = client.me()
|
|
32
|
+
except APIError as exc:
|
|
33
|
+
if exc.status != 403 or getattr(credential, "kind", "key") != "key":
|
|
34
|
+
raise
|
|
35
|
+
quick = Client(credential, server, transport=transport)
|
|
36
|
+
try:
|
|
37
|
+
quick.capabilities()
|
|
38
|
+
finally:
|
|
39
|
+
quick.close()
|
|
40
|
+
return "quick", QUICK_JOB_TYPES
|
|
41
|
+
advertised = me.get("job_types") if isinstance(me, dict) else None
|
|
42
|
+
types = frozenset(t for t in advertised or ["convert"] if t in JOB_TYPES)
|
|
43
|
+
return "external", types or frozenset({"convert"})
|
|
44
|
+
finally:
|
|
45
|
+
client.close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def open_client(credential, server, *, transport=None):
|
|
49
|
+
"""(client, profile) for the full-screen app and the plain CLI."""
|
|
50
|
+
kind, _ = detect_profile(credential, server, transport=transport)
|
|
51
|
+
cls = ExternalClient if kind == "external" else Client
|
|
52
|
+
return cls(credential, server, transport=transport), kind
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def submit_external(client, content: bytes, source_vendor: str, target_vendor, job_type: str,
|
|
56
|
+
options: dict, idempotency_key: str):
|
|
57
|
+
"""POST /jobs with inline UTF-8 text. ``technology`` follows the vendor code."""
|
|
58
|
+
text = content.decode("utf-8") # callers snapshot UTF-8 text only
|
|
59
|
+
body = {**options, "source_vendor": source_vendor, "source_config": text}
|
|
60
|
+
body.setdefault("technology", device_type_of(source_vendor))
|
|
61
|
+
if target_vendor:
|
|
62
|
+
body["target_vendor"] = target_vendor
|
|
63
|
+
if job_type != "convert":
|
|
64
|
+
body["job_type"] = job_type
|
|
65
|
+
return client.submit_job(body, idempotency_key)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
ANALYSIS_RESULTS = {"validate": "validation", "optimize": "optimization"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _save_json(output, job_id: str, filename: str, value):
|
|
72
|
+
# Canonical bytes keep a repeat download identical (JSONB key order).
|
|
73
|
+
content = json.dumps(value, indent=2, sort_keys=True, default=str).encode()
|
|
74
|
+
return output.save_artifact(job_id, filename, content, hashlib.sha256(content).hexdigest())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def download_external(client, job_id: str, output) -> list:
|
|
78
|
+
"""Translated config, the structured report and the evidence workbook, hash-verified.
|
|
79
|
+
|
|
80
|
+
A validate or optimize job emits no translation: GET /jobs/{id} carries its
|
|
81
|
+
result, saved as ``<job>.validation.json`` / ``<job>.optimization.json``.
|
|
82
|
+
"""
|
|
83
|
+
saved = []
|
|
84
|
+
job = client.job(job_id)
|
|
85
|
+
key = ANALYSIS_RESULTS.get(job.get("job_type")) if isinstance(job, dict) else None
|
|
86
|
+
if key:
|
|
87
|
+
result = job.get(key)
|
|
88
|
+
if result is None:
|
|
89
|
+
raise APIError(409, "job_not_completed", detail={"job_id": job_id, "status": job.get("status")})
|
|
90
|
+
saved.append(_save_json(output, job_id, f"{key}.json", {
|
|
91
|
+
"job_id": job_id, "job_type": job["job_type"], "status": job.get("status"),
|
|
92
|
+
"source_vendor": job.get("source_vendor"), key: result}))
|
|
93
|
+
workbook = client.evidence_workbook(job_id)
|
|
94
|
+
if workbook:
|
|
95
|
+
saved.append(output.save_artifact(job_id, "evidence.xlsx", workbook,
|
|
96
|
+
hashlib.sha256(workbook).hexdigest()))
|
|
97
|
+
return saved
|
|
98
|
+
translated = client.translated(job_id)
|
|
99
|
+
if translated:
|
|
100
|
+
saved.append(output.save_artifact(job_id, "translated.txt", translated,
|
|
101
|
+
hashlib.sha256(translated).hexdigest()))
|
|
102
|
+
saved.append(_save_json(output, job_id, "report.json", client.report(job_id)))
|
|
103
|
+
workbook = client.evidence_workbook(job_id)
|
|
104
|
+
if workbook:
|
|
105
|
+
saved.append(output.save_artifact(job_id, "evidence.xlsx", workbook,
|
|
106
|
+
hashlib.sha256(workbook).hexdigest()))
|
|
107
|
+
return saved
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""NetConverter CLI identity: the setup banner and the app's one-row header."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .palette import ACCENT, DIM, ERROR, FG, MUTED, SUCCESS, WARNING
|
|
9
|
+
|
|
10
|
+
PRODUCT = 'NetConverter CLI'
|
|
11
|
+
TAGLINE = 'Any-vendor migrations · your AI over MCP · configs never reach the model'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def wordmark(version=True):
|
|
15
|
+
text = Text('◆ ', style=f'bold {ACCENT}')
|
|
16
|
+
text.append('Net', style=f'bold {FG}')
|
|
17
|
+
text.append('Converter', style=f'bold {ACCENT}')
|
|
18
|
+
if version:
|
|
19
|
+
text.append(f' v{__version__}', style=MUTED)
|
|
20
|
+
return text
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def folder_label(folder):
|
|
24
|
+
label = str(folder)
|
|
25
|
+
home = str(Path.home())
|
|
26
|
+
return '~' + label[len(home):] if label.startswith(home + '/') else label
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def banner(folder, compact=False, width=120):
|
|
30
|
+
"""Multi-line identity block for `netconverter setup` and plain output."""
|
|
31
|
+
from .presentation import literal
|
|
32
|
+
text = wordmark()
|
|
33
|
+
text.append(' CLI\n', style=f'bold {FG}')
|
|
34
|
+
text.append(TAGLINE + '\n', style=MUTED)
|
|
35
|
+
folder_text = literal('Folder ' + folder_label(folder), DIM)
|
|
36
|
+
folder_text.truncate(max(16, width - 4), overflow='ellipsis')
|
|
37
|
+
text.append(folder_text)
|
|
38
|
+
return text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _pill(dot, label, color, value=None):
|
|
42
|
+
text = Text(dot + ' ', style=color)
|
|
43
|
+
text.append(label, style=FG if color != DIM else MUTED)
|
|
44
|
+
if value:
|
|
45
|
+
text.append(' ' + value, style=MUTED)
|
|
46
|
+
return text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def header(width, *, server='checking', profile='', bridge=None, model=None, allowance=''):
|
|
50
|
+
"""One row: wordmark left, status pills right; lower-priority pills drop as width shrinks.
|
|
51
|
+
|
|
52
|
+
server: connected | checking | offline. bridge: (client, ago) or None.
|
|
53
|
+
"""
|
|
54
|
+
server_pill = {
|
|
55
|
+
'connected': _pill('●', 'Server', SUCCESS, profile or None),
|
|
56
|
+
'offline': _pill('●', 'Server', ERROR, 'offline · /allowance'),
|
|
57
|
+
}.get(server, _pill('●', 'Server', WARNING, 'checking'))
|
|
58
|
+
# (priority, pill) in display order; lower priority numbers survive a narrow terminal.
|
|
59
|
+
pills = [(0, server_pill)]
|
|
60
|
+
if bridge:
|
|
61
|
+
pills.append((2, _pill('◆', 'Bridge', ACCENT, f'{bridge[0]} · {bridge[1]}')))
|
|
62
|
+
else:
|
|
63
|
+
pills.append((4, _pill('◇', 'Bridge', DIM, 'idle')))
|
|
64
|
+
pills.append((3, _pill('●', 'Model', SUCCESS, model)) if model else (5, _pill('○', 'Model', DIM, 'none')))
|
|
65
|
+
if allowance:
|
|
66
|
+
pills.append((1, Text(allowance, style=MUTED)))
|
|
67
|
+
left = wordmark()
|
|
68
|
+
budget = max(0, width - left.cell_len - 4)
|
|
69
|
+
kept, used = set(), 0
|
|
70
|
+
for index, (_, pill) in sorted(enumerate(pills), key=lambda item: item[1][0]):
|
|
71
|
+
if used + pill.cell_len + (3 if kept else 0) <= budget:
|
|
72
|
+
kept.add(index)
|
|
73
|
+
used += pill.cell_len + (3 if len(kept) > 1 else 0)
|
|
74
|
+
right = Text(' ').join(pill for index, (_, pill) in enumerate(pills) if index in kept)
|
|
75
|
+
grid = Table.grid(expand=True)
|
|
76
|
+
grid.add_column(no_wrap=True)
|
|
77
|
+
grid.add_column(justify='right', no_wrap=True)
|
|
78
|
+
grid.add_row(left, right)
|
|
79
|
+
return grid
|