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,365 @@
|
|
|
1
|
+
"""Inline keyboard choices. No model controls paths, settings, or credentials.
|
|
2
|
+
|
|
3
|
+
Every menu is built from the server catalog (``Controller.catalog``): the full
|
|
4
|
+
API offers every vendor and path it accepts; a 0.1.x pilot key keeps the
|
|
5
|
+
ASA → Palo Alto settings the pilot API requires.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .catalog import label
|
|
12
|
+
from .workspace import WorkspaceError
|
|
13
|
+
|
|
14
|
+
# Downloaded artifacts are named "<job id>.<file>"; they are outputs, not sources.
|
|
15
|
+
_ARTIFACT = re.compile(r'(?:[a-z]{3}_[A-Za-z0-9]{6,}|[0-9a-f]{8}-[0-9a-f-]{27})\.')
|
|
16
|
+
_SKIP_SUFFIXES = {'.zip', '.xlsx', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.docx', '.pptx', '.gz', '.tgz', '.whl'}
|
|
17
|
+
# First match wins; only codes the server offers are ever suggested.
|
|
18
|
+
_SIGNATURES = [
|
|
19
|
+
('cisco_asa', re.compile(r'^(?:ASA Version|: Saved|access-list \S+ extended|object-group network)', re.M)),
|
|
20
|
+
('fortinet', re.compile(r'^config (?:firewall|system) ', re.M)),
|
|
21
|
+
('palo_alto_panorama', re.compile(r'<(?:device-group|panorama)>')),
|
|
22
|
+
('palo_alto_xml', re.compile(r'<config\b')),
|
|
23
|
+
('palo_alto_set', re.compile(r'^set (?:deviceconfig|rulebase|address|zone|network|shared) ', re.M)),
|
|
24
|
+
('juniper_set_', re.compile(r'^set (?:system|interfaces|protocols|security|vlans|routing-options) ', re.M)),
|
|
25
|
+
('juniper_junos_', re.compile(r'^(?:system|interfaces|protocols|security|vlans) \{', re.M)),
|
|
26
|
+
('aruba_cx_switch', re.compile(r'^(?:!Version ArubaOS-CX|vsx$|interface 1/1/)', re.M)),
|
|
27
|
+
('aruba_os_switch', re.compile(r'Configuration Editor|^vlan \d+\s*\n\s+name ', re.M)),
|
|
28
|
+
('cisco_ios_xe_', re.compile(r'^(?:Building configuration|version 1[5-7]\.|interface GigabitEthernet|ip routing)', re.M)),
|
|
29
|
+
('cisco_fmc', re.compile(r'"(?:accessPolicies|deviceRecords|objects)"\s*:')),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def guess_vendor(text, candidates):
|
|
34
|
+
"""A suggested source vendor, read locally from the file head; None when unsure."""
|
|
35
|
+
for code, pattern in _SIGNATURES:
|
|
36
|
+
if pattern.search(text):
|
|
37
|
+
for candidate in candidates:
|
|
38
|
+
if candidate == code or (code.endswith('_') and candidate.startswith(code)):
|
|
39
|
+
return candidate
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Guided:
|
|
44
|
+
def __init__(self, app):
|
|
45
|
+
self.app = app
|
|
46
|
+
self.root = app.controller.workspace.root
|
|
47
|
+
self.options = {}
|
|
48
|
+
self.job_type = None
|
|
49
|
+
self.filename = None
|
|
50
|
+
self.vendor = None
|
|
51
|
+
self.model_name = None
|
|
52
|
+
self.model_id = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def controller(self):
|
|
56
|
+
return self.app.controller
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def catalog(self):
|
|
60
|
+
return self.app.controller.catalog
|
|
61
|
+
|
|
62
|
+
def choose(self, title, choices, callback, highlight=0):
|
|
63
|
+
self.app.ask_choice(title, choices, callback, highlight=highlight)
|
|
64
|
+
|
|
65
|
+
def text(self, title, callback, *, secret=False, hint=''):
|
|
66
|
+
self.app.ask_text(title, callback, secret=secret, hint=hint)
|
|
67
|
+
|
|
68
|
+
# -- entry points ----------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def start(self, job_type):
|
|
71
|
+
"""/convert, /validate, /optimize (job_type) or /open (None)."""
|
|
72
|
+
if not self.catalog.loaded:
|
|
73
|
+
self.app.load_catalog(lambda: self.start(job_type))
|
|
74
|
+
return
|
|
75
|
+
if job_type and not self.catalog.supports(job_type):
|
|
76
|
+
self.app.show_error(f'{label(job_type)} is not available for this credential yet. '
|
|
77
|
+
f'Available: {", ".join(label(t) for t in self.catalog.job_types)}.')
|
|
78
|
+
return
|
|
79
|
+
self.job_type = job_type
|
|
80
|
+
self.options = {}
|
|
81
|
+
selected = self.selected()
|
|
82
|
+
if job_type and selected and selected.get('vendor') in self.catalog.sources(job_type):
|
|
83
|
+
name = selected.get('display_name') or selected.get('path')
|
|
84
|
+
self.choose(f'{label(job_type)} which configuration?', [
|
|
85
|
+
(f'{name} · {self.catalog.name(selected["vendor"])}', True),
|
|
86
|
+
('Choose another file', False)],
|
|
87
|
+
lambda keep: self.after_open(job_type) if keep else self.browse())
|
|
88
|
+
return
|
|
89
|
+
if job_type and selected.get('vendor'):
|
|
90
|
+
self.app.write_note(f'{self.catalog.name(selected["vendor"])} is not a {label(job_type).lower()} source '
|
|
91
|
+
f'on this server; choose the source file.')
|
|
92
|
+
self.browse()
|
|
93
|
+
|
|
94
|
+
def convert(self):
|
|
95
|
+
self.start('convert')
|
|
96
|
+
|
|
97
|
+
def open(self, following=None, folder=None):
|
|
98
|
+
self.job_type = following if following in {'convert', 'validate', 'optimize'} else None
|
|
99
|
+
self.browse(folder)
|
|
100
|
+
|
|
101
|
+
def selected(self):
|
|
102
|
+
state = self.controller.state
|
|
103
|
+
return state.get('configurations', {}).get(state.get('selected'), {})
|
|
104
|
+
|
|
105
|
+
# -- file and source vendor ------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def browse(self, folder=None):
|
|
108
|
+
folder = Path(folder or self.root)
|
|
109
|
+
limit = self.controller.max_bytes()
|
|
110
|
+
choices = []
|
|
111
|
+
if folder != self.root:
|
|
112
|
+
choices.append(('↰ ..', folder.parent))
|
|
113
|
+
try:
|
|
114
|
+
entries = sorted(folder.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
115
|
+
for path in entries:
|
|
116
|
+
if path.name.startswith(('.', '~$')) or path.is_symlink() or _ARTIFACT.match(path.name):
|
|
117
|
+
continue
|
|
118
|
+
if not path.resolve().is_relative_to(self.root):
|
|
119
|
+
continue
|
|
120
|
+
if path.is_dir():
|
|
121
|
+
choices.append((path.name + '/', path))
|
|
122
|
+
elif path.suffix.lower() not in _SKIP_SUFFIXES:
|
|
123
|
+
size = path.stat().st_size
|
|
124
|
+
if size:
|
|
125
|
+
over = f' · over the {_size(limit)} limit' if size > limit else ''
|
|
126
|
+
choices.append((f'{path.name} · {_size(size)}{over}', path))
|
|
127
|
+
except OSError:
|
|
128
|
+
self.app.show_error('Cannot list this folder. Check its permissions.')
|
|
129
|
+
choices = choices[:200] + [('Type a relative file path', None)]
|
|
130
|
+
title = {'convert': 'Convert which file?', 'validate': 'Validate which file?',
|
|
131
|
+
'optimize': 'Optimize which file?'}.get(self.job_type, 'Choose a configuration')
|
|
132
|
+
self.choose(title, choices, lambda path: self.file_chosen(path, folder))
|
|
133
|
+
|
|
134
|
+
def file_chosen(self, path, folder=None):
|
|
135
|
+
if path is None:
|
|
136
|
+
self.text('Relative configuration path',
|
|
137
|
+
lambda value: self.file_chosen(self.root / value.strip().strip('"'), folder))
|
|
138
|
+
return
|
|
139
|
+
try:
|
|
140
|
+
path = self.controller.workspace._inside(Path(path))
|
|
141
|
+
if path.is_dir():
|
|
142
|
+
self.browse(path)
|
|
143
|
+
return
|
|
144
|
+
_, content, _ = self.controller.workspace.snapshot(str(path.relative_to(self.root)),
|
|
145
|
+
self.controller.max_bytes())
|
|
146
|
+
except (WorkspaceError, OSError):
|
|
147
|
+
self.app.show_error(f'Choose a UTF-8 text configuration inside this folder, '
|
|
148
|
+
f'up to {self.controller.max_bytes():,} bytes.')
|
|
149
|
+
self.browse(folder)
|
|
150
|
+
return
|
|
151
|
+
self.filename = str(path.relative_to(self.root))
|
|
152
|
+
sources = self.catalog.sources(self.job_type or 'validate')
|
|
153
|
+
if len(sources) == 1:
|
|
154
|
+
self.vendor_chosen(sources[0])
|
|
155
|
+
return
|
|
156
|
+
choices = self.catalog.grouped(sources)
|
|
157
|
+
guess = guess_vendor(content[:65536].decode('utf-8', 'replace'), [c for _, c in choices])
|
|
158
|
+
highlight = 0
|
|
159
|
+
if guess:
|
|
160
|
+
highlight = [c for _, c in choices].index(guess)
|
|
161
|
+
choices[highlight] = (choices[highlight][0] + ' · detected', guess)
|
|
162
|
+
self.choose(f'What does {Path(self.filename).name} contain?', choices, self.vendor_chosen,
|
|
163
|
+
highlight=highlight)
|
|
164
|
+
|
|
165
|
+
def vendor_chosen(self, vendor):
|
|
166
|
+
self.vendor = vendor
|
|
167
|
+
if self.controller.external:
|
|
168
|
+
# Nothing leaves this machine until the review's explicit Submit.
|
|
169
|
+
self.app.open_selected((self.filename, vendor, self.job_type))
|
|
170
|
+
return
|
|
171
|
+
self.choose(f'Upload {self.filename} as {self.catalog.name(vendor)}?', [
|
|
172
|
+
('Upload this file directly to NetConverter', True), ('Choose another file', False)],
|
|
173
|
+
lambda approved: self.app.open_selected((self.filename, vendor, self.job_type))
|
|
174
|
+
if approved else self.browse())
|
|
175
|
+
|
|
176
|
+
def after_open(self, following):
|
|
177
|
+
"""Called once the selected configuration is pinned (uploaded for pilot keys)."""
|
|
178
|
+
self.job_type = following
|
|
179
|
+
self.options = {}
|
|
180
|
+
if following == 'convert':
|
|
181
|
+
self.target()
|
|
182
|
+
elif following in {'validate', 'optimize'}:
|
|
183
|
+
self.review()
|
|
184
|
+
|
|
185
|
+
# -- conversion settings ---------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def target(self):
|
|
188
|
+
source = self.selected().get('vendor')
|
|
189
|
+
targets = self.catalog.targets(source)
|
|
190
|
+
if not targets:
|
|
191
|
+
self.app.show_error(f'{self.catalog.name(source)} has no conversion target on this server. '
|
|
192
|
+
f'Use /validate to check it instead.')
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
def chosen(value):
|
|
196
|
+
self.options['target_vendor'] = value
|
|
197
|
+
if self.catalog.profile == 'quick':
|
|
198
|
+
self.quick_fields([('panos_target_version', 'panos_versions', 'PAN-OS version'),
|
|
199
|
+
('routing_engine', 'routing_engines', 'Routing engine'),
|
|
200
|
+
('pipeline_mode', 'pipeline_modes', 'Conversion mode')])
|
|
201
|
+
else:
|
|
202
|
+
self.review()
|
|
203
|
+
self.choose(f'Convert {self.catalog.name(source)} to', self.catalog.grouped(targets), chosen)
|
|
204
|
+
|
|
205
|
+
def quick_fields(self, fields):
|
|
206
|
+
"""Pilot keys: the pilot API requires these Palo Alto settings explicitly."""
|
|
207
|
+
if not fields:
|
|
208
|
+
self.panorama()
|
|
209
|
+
return
|
|
210
|
+
field, key, title = fields[0]
|
|
211
|
+
values = self.catalog.quick.get(key) or []
|
|
212
|
+
if not values:
|
|
213
|
+
self.app.show_error('Load supported settings with /allowance, then use /convert.')
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
def selected(value):
|
|
217
|
+
self.options[field] = value
|
|
218
|
+
self.quick_fields(fields[1:])
|
|
219
|
+
self.choose(title, [(label(v), v) for v in values], selected)
|
|
220
|
+
|
|
221
|
+
def panorama(self):
|
|
222
|
+
if self.options['target_vendor'] == 'palo_alto_panorama':
|
|
223
|
+
self.required_text('Panorama device group', 'panorama_device_group',
|
|
224
|
+
lambda: self.required_text('Panorama template', 'panorama_template', self.cleanup))
|
|
225
|
+
else:
|
|
226
|
+
self.cleanup()
|
|
227
|
+
|
|
228
|
+
def required_text(self, title, field, following):
|
|
229
|
+
def answer(value):
|
|
230
|
+
if not value.strip():
|
|
231
|
+
self.app.show_error('This value is required.')
|
|
232
|
+
self.required_text(title, field, following)
|
|
233
|
+
else:
|
|
234
|
+
self.options[field] = value.strip()
|
|
235
|
+
following()
|
|
236
|
+
self.text(title, answer)
|
|
237
|
+
|
|
238
|
+
def cleanup(self):
|
|
239
|
+
def answer(value):
|
|
240
|
+
self.options['remove_unused_objects'] = value
|
|
241
|
+
self.choose('Advanced settings', [('Use the selected mode’s server presets', False),
|
|
242
|
+
('Customize App-ID, profiles, logging and mappings', True)],
|
|
243
|
+
lambda customize: self.advanced() if customize else self.review())
|
|
244
|
+
self.choose('Remove unused objects during validated conversion?',
|
|
245
|
+
[('Keep existing objects', False), ('Remove verified unused objects', True)], answer)
|
|
246
|
+
|
|
247
|
+
def advanced(self, index=0):
|
|
248
|
+
choices = [
|
|
249
|
+
('enable_appid', 'App-ID', [('Use mode preset', None), ('Enable', True), ('Disable', False)]),
|
|
250
|
+
('app_id_implementation', 'App-ID implementation',
|
|
251
|
+
[('Use mode preset', None), ('Stack above original', 'dual_stack'), ('Replace original', 'force')]),
|
|
252
|
+
('add_security_profiles', 'Security profiles', [('Use mode preset', None), ('Enable', True), ('Disable', False)]),
|
|
253
|
+
('log_all_rules', 'Log all rules', [('Use mode preset', None), ('Enable', True), ('Disable', False)]),
|
|
254
|
+
('implicit_deny', 'Explicit deny-all',
|
|
255
|
+
[('Use mode preset', None), ('Enable', 'explicit-deny-all'), ('Disable', 'none')])]
|
|
256
|
+
if index == len(choices):
|
|
257
|
+
self.text('Security profile group (Enter to use preset)', self.profile_group)
|
|
258
|
+
return
|
|
259
|
+
field, title, options = choices[index]
|
|
260
|
+
|
|
261
|
+
def answer(value):
|
|
262
|
+
if value is not None:
|
|
263
|
+
self.options[field] = value
|
|
264
|
+
self.advanced(index + 1)
|
|
265
|
+
self.choose(title, options, answer)
|
|
266
|
+
|
|
267
|
+
def profile_group(self, value):
|
|
268
|
+
if value.strip():
|
|
269
|
+
self.options['default_profile_group'] = value.strip()
|
|
270
|
+
self.text('Zone mapping JSON (Enter for no override)', self.zone_mapping,
|
|
271
|
+
hint='Example: {"inside":"Trust"}. Values go directly to NetConverter.')
|
|
272
|
+
|
|
273
|
+
def zone_mapping(self, value):
|
|
274
|
+
if value.strip():
|
|
275
|
+
try:
|
|
276
|
+
mapping = json.loads(value)
|
|
277
|
+
if not isinstance(mapping, dict) or not all(isinstance(k, str) and isinstance(v, str)
|
|
278
|
+
for k, v in mapping.items()):
|
|
279
|
+
raise ValueError()
|
|
280
|
+
self.options['zone_mapping'] = mapping
|
|
281
|
+
except ValueError:
|
|
282
|
+
self.app.show_error('Use a JSON object of source and target zone names.')
|
|
283
|
+
self.text('Zone mapping JSON (Enter for no override)', self.zone_mapping)
|
|
284
|
+
return
|
|
285
|
+
self.review()
|
|
286
|
+
|
|
287
|
+
# -- review and submit -----------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def review(self):
|
|
290
|
+
from .presentation import card, details_view, literal
|
|
291
|
+
selected = self.selected()
|
|
292
|
+
from rich.console import Group
|
|
293
|
+
from .palette import MUTED
|
|
294
|
+
summary = {'job_type': self.job_type, 'file': selected.get('display_name') or selected.get('path'),
|
|
295
|
+
'source_vendor': self.catalog.name(selected.get('vendor'))}
|
|
296
|
+
for field, value in self.options.items():
|
|
297
|
+
summary[field] = self.catalog.name(value) if field == 'target_vendor' else value
|
|
298
|
+
cost = {'convert': 'one conversion', 'validate': 'one analysis', 'optimize': 'one analysis'}[self.job_type]
|
|
299
|
+
sends = ('Submit sends the file directly to NetConverter over HTTPS'
|
|
300
|
+
if self.controller.external else 'Submit starts the job on NetConverter')
|
|
301
|
+
self.app.write(card('Review', Group(details_view(summary), literal(''),
|
|
302
|
+
literal(f'{sends} and uses {cost}.', MUTED))))
|
|
303
|
+
self.choose('Submit this job?',
|
|
304
|
+
[('Submit to NetConverter', True), ('Start over', False)],
|
|
305
|
+
lambda submit: self.app.submit_job(self.job_type, self.options) if submit
|
|
306
|
+
else self.start(self.job_type))
|
|
307
|
+
|
|
308
|
+
# -- model ------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def model(self):
|
|
311
|
+
self.choose('Model provider', [('OpenAI', 'openai'), ('Anthropic', 'anthropic'), ('Gemini', 'gemini'),
|
|
312
|
+
('Ollama · local', 'ollama'), ('Guided commands · no model', 'none')],
|
|
313
|
+
self.provider)
|
|
314
|
+
|
|
315
|
+
def provider(self, name):
|
|
316
|
+
self.model_name = name
|
|
317
|
+
if name == 'none':
|
|
318
|
+
self.app.connect_model_inline(name, '', '')
|
|
319
|
+
else:
|
|
320
|
+
self.text('Exact API model ID', self.model_value, hint='Use a model available to your API account.')
|
|
321
|
+
|
|
322
|
+
def model_value(self, value):
|
|
323
|
+
if not value.strip():
|
|
324
|
+
self.app.show_error('Enter an explicit model ID.')
|
|
325
|
+
self.provider(self.model_name)
|
|
326
|
+
return
|
|
327
|
+
self.model_id = value.strip()
|
|
328
|
+
if self.model_name == 'ollama':
|
|
329
|
+
self.app.connect_model_inline(self.model_name, self.model_id, '')
|
|
330
|
+
else:
|
|
331
|
+
self.text(f'{self.model_name} API key · hidden',
|
|
332
|
+
lambda key: self.app.connect_model_inline(self.model_name, self.model_id, key.strip()),
|
|
333
|
+
secret=True, hint='Paste here; Enter uses a saved key if available. '
|
|
334
|
+
'This value is never added to the conversation.')
|
|
335
|
+
|
|
336
|
+
# -- AI clients --------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def connect(self):
|
|
339
|
+
from .connect import CLIENTS, configured_clients, plan
|
|
340
|
+
done = configured_clients(self.root)
|
|
341
|
+
self.choose('Connect which AI assistant to this folder?',
|
|
342
|
+
[(spec.title + (' · already connected' if spec.title in done else ''), key)
|
|
343
|
+
for key, spec in CLIENTS.items()],
|
|
344
|
+
lambda key: self.connect_review(plan(key, self.root)))
|
|
345
|
+
|
|
346
|
+
def connect_review(self, change):
|
|
347
|
+
from .presentation import card, literal
|
|
348
|
+
from .palette import MUTED
|
|
349
|
+
from rich.console import Group
|
|
350
|
+
from .connect import CLIENTS
|
|
351
|
+
heading = f'Connect {change.title}' if change.key in CLIENTS else change.title
|
|
352
|
+
self.app.write(card(heading, Group(literal(change.summary), literal(change.preview, MUTED))))
|
|
353
|
+
if not change.can_apply:
|
|
354
|
+
self.app.write_note(change.manual)
|
|
355
|
+
return
|
|
356
|
+
self.choose(f'Apply this change to {change.where}?', [('Apply', True), ('Cancel', False)],
|
|
357
|
+
lambda ok: self.app.apply_connect(change) if ok else self.app.write_note('Nothing was changed.'))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _size(value):
|
|
361
|
+
if value >= 1024 * 1024:
|
|
362
|
+
return f'{value / 1024 / 1024:.1f} MB'
|
|
363
|
+
if value >= 1024:
|
|
364
|
+
return f'{value / 1024:.0f} KB'
|
|
365
|
+
return f'{value} B'
|