packet-tracer-skill 0.1.0

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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/LICENSE.Twofish-BSD-3-Clause.txt +29 -0
  3. package/README.md +687 -0
  4. package/SKILL.md +221 -0
  5. package/bin/packet-tracer-skill.js +635 -0
  6. package/examples/blueprint_minimal.json +46 -0
  7. package/package.json +42 -0
  8. package/references/packettracer-sample-catalog.json +21410 -0
  9. package/references/packettracer-sample-catalog.md +1124 -0
  10. package/references/pkt-format.md +57 -0
  11. package/references/xml-skeleton-notes.md +44 -0
  12. package/requirements-dev.txt +1 -0
  13. package/requirements.txt +6 -0
  14. package/scripts/build_sample_catalog.py +65 -0
  15. package/scripts/donor_diagnostics.py +35 -0
  16. package/scripts/generate_pkt.py +1264 -0
  17. package/scripts/install_skill.py +71 -0
  18. package/scripts/intent_parser.py +712 -0
  19. package/scripts/packet_tracer_env.py +278 -0
  20. package/scripts/pkt_builder.py +15 -0
  21. package/scripts/pkt_codec.py +181 -0
  22. package/scripts/pkt_editor.py +752 -0
  23. package/scripts/pkt_transformer.py +541 -0
  24. package/scripts/sample_catalog.py +385 -0
  25. package/scripts/sample_selector.py +156 -0
  26. package/scripts/setup.ps1 +26 -0
  27. package/scripts/twofish_diagnostics.py +91 -0
  28. package/scripts/vendor/README.md +46 -0
  29. package/scripts/vendor/twofish.py +81 -0
  30. package/scripts/workspace_repair.py +441 -0
  31. package/templates/pt900/base_empty.xml +21 -0
  32. package/templates/pt900/device_library/pc.xml +20 -0
  33. package/templates/pt900/device_library/printer.xml +432 -0
  34. package/templates/pt900/device_library/router.xml +16 -0
  35. package/templates/pt900/device_library/switch.xml +38 -0
@@ -0,0 +1,1264 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import copy
7
+ from dataclasses import asdict, dataclass
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ import xml.etree.ElementTree as ET
15
+
16
+ from intent_parser import IntentPlan, parse_intent
17
+ from packet_tracer_env import (
18
+ get_packet_tracer_compatibility_donor,
19
+ inspect_packet_tracer_compatibility_donor,
20
+ require_packet_tracer_exe,
21
+ )
22
+ from pkt_builder import build_packet_tracer_xml
23
+ from pkt_codec import decode_pkt_file, decode_pkt_modern, encode_pkt_modern
24
+ from pkt_editor import apply_plan_operations, decode_pkt_to_root, edit_pkt_file, inventory_devices, inventory_links, inventory_root
25
+ from pkt_transformer import transform_from_blueprint
26
+ from sample_catalog import ReferencePattern, load_catalog, load_reference_catalog
27
+ from sample_selector import rank_reference_samples, rank_samples, select_best_sample
28
+ from workspace_repair import inspect_donor_coherence, inspect_workspace_integrity, validate_donor_coherence, validate_workspace_integrity
29
+
30
+
31
+ if hasattr(sys.stdout, "reconfigure"):
32
+ try:
33
+ sys.stdout.reconfigure(encoding="utf-8")
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ class PlanningError(RuntimeError):
39
+ def __init__(self, message: str, plan: IntentPlan) -> None:
40
+ super().__init__(message)
41
+ self.plan = plan
42
+
43
+ def to_dict(self) -> dict[str, object]:
44
+ return {
45
+ "error": str(self),
46
+ "blocking_gaps": self.plan.blocking_gaps,
47
+ "parse_warnings": self.plan.parse_warnings,
48
+ "device_requirements": self.plan.device_requirements,
49
+ "vlan_ids": self.plan.vlan_ids,
50
+ "topology_requirements": self.plan.topology_requirements,
51
+ }
52
+
53
+
54
+ STRICT_COMPATIBILITY_GAP = (
55
+ "Strict 9.0 generation requires a compatible local Packet Tracer 9.0 donor lab. "
56
+ "Set PACKET_TRACER_COMPAT_DONOR explicitly or let the repo auto-detect one."
57
+ )
58
+
59
+
60
+ def _compat_donor_details() -> tuple[Path | None, str | None]:
61
+ details = inspect_packet_tracer_compatibility_donor()
62
+ return details.resolved_path, details.donor_version
63
+
64
+
65
+ def _apply_prompt_compatibility_requirements(plan: IntentPlan) -> IntentPlan:
66
+ prepared = prepare_generation_plan(plan)
67
+ if prepared.goal != "edit":
68
+ donor, _ = _compat_donor_details()
69
+ if donor is None and STRICT_COMPATIBILITY_GAP not in prepared.blocking_gaps:
70
+ prepared.blocking_gaps.append(STRICT_COMPATIBILITY_GAP)
71
+ return prepared
72
+
73
+
74
+ def _link_schema_summary(root: ET.Element) -> dict[str, object]:
75
+ cable = root.find(".//LINKS/LINK/CABLE")
76
+ if cable is None:
77
+ return {"link_schema_mode": "none", "link_schema_missing_fields": []}
78
+ from_ref = cable.findtext("FROM", default="")
79
+ mode = "save_ref_id" if from_ref.startswith("save-ref-id:") else ("numeric_index" if from_ref.isdigit() else "unknown")
80
+ required = ["FUNCTIONAL", "GEO_VIEW_COLOR", "IS_MANAGED_IN_RACK_VIEW"]
81
+ missing = [tag for tag in required if cable.find(tag) is None]
82
+ if mode == "save_ref_id":
83
+ mode = "save_ref_id_complete" if not missing else "save_ref_id_missing_fields"
84
+ return {"link_schema_mode": mode, "link_schema_missing_fields": missing}
85
+
86
+
87
+ @dataclass
88
+ class TopologyPlan:
89
+ topology_archetype: str
90
+ devices: list[dict[str, object]]
91
+ links: list[dict[str, object]]
92
+ layout: dict[str, dict[str, int]]
93
+ port_map: dict[str, list[str]]
94
+
95
+
96
+ @dataclass
97
+ class ConfigPlan:
98
+ switch_ops: list[dict[str, object]]
99
+ router_ops: list[dict[str, object]]
100
+ server_ops: list[dict[str, object]]
101
+ wireless_ops: list[dict[str, object]]
102
+ end_device_ops: list[dict[str, object]]
103
+ management_ops: list[dict[str, object]]
104
+ assumptions_used: list[str]
105
+
106
+
107
+ @dataclass
108
+ class DonorArchetypePlan:
109
+ compat_donor: str
110
+ donor_capacity: dict[str, object]
111
+ kept_devices: list[str]
112
+ pruned_devices: list[str]
113
+ renamed_devices: list[dict[str, str]]
114
+ mutation_groups: list[dict[str, object]]
115
+ layout_strategy: str
116
+
117
+
118
+ def _estimate_plan(topology_plan: TopologyPlan, config_plan: ConfigPlan) -> dict[str, object]:
119
+ device_count = len(topology_plan.devices)
120
+ link_count = len(topology_plan.links)
121
+ op_count = sum(
122
+ len(bucket)
123
+ for bucket in [
124
+ config_plan.switch_ops,
125
+ config_plan.router_ops,
126
+ config_plan.server_ops,
127
+ config_plan.wireless_ops,
128
+ config_plan.end_device_ops,
129
+ config_plan.management_ops,
130
+ ]
131
+ )
132
+ complexity = "small"
133
+ if device_count >= 20 or link_count >= 18 or op_count >= 20:
134
+ complexity = "medium"
135
+ if device_count >= 40 or link_count >= 40 or op_count >= 40:
136
+ complexity = "large"
137
+ return {
138
+ "device_count": device_count,
139
+ "link_count": link_count,
140
+ "config_operation_count": op_count,
141
+ "complexity": complexity,
142
+ }
143
+
144
+
145
+ def _preflight_validation(plan: IntentPlan, topology_plan: TopologyPlan, config_plan: ConfigPlan) -> dict[str, object]:
146
+ issues = list(plan.blocking_gaps)
147
+ warnings = list(plan.parse_warnings)
148
+ if topology_plan.topology_archetype == "chain" and len([device for device in topology_plan.devices if _device_kind(device) == "Switch"]) < 2:
149
+ warnings.append("Chain archetype selected with fewer than two switches.")
150
+ if "router_on_a_stick" in plan.capabilities and not any(op.get("op") == "set_subinterface" for op in config_plan.router_ops):
151
+ issues.append("Router-on-a-stick was requested but no router subinterfaces were planned.")
152
+ if "wireless_ap" in plan.capabilities and not any(op.get("op") == "set_ssid" for op in config_plan.wireless_ops):
153
+ warnings.append("Wireless access points are present but no SSID mutation was planned.")
154
+ if plan.device_requirements.get("Printer", 0) and not any(device.get("type") == "Printer" for device in topology_plan.devices):
155
+ issues.append("Prompt requested printers but topology plan did not allocate printer devices.")
156
+ status = "blocked" if issues else ("warning" if warnings else "ok")
157
+ return {
158
+ "status": status,
159
+ "issues": issues,
160
+ "warnings": warnings,
161
+ }
162
+
163
+
164
+ def _autofix_summary(plan: IntentPlan, validation: dict[str, object]) -> dict[str, object]:
165
+ applied = list(plan.assumptions_used)
166
+ pending = list(validation.get("issues", []))
167
+ return {
168
+ "applied": applied,
169
+ "pending_manual_input": pending,
170
+ }
171
+
172
+
173
+ def _default_name_for_type(device_type: str, index: int) -> str:
174
+ return {
175
+ "Router": f"R{index}",
176
+ "Switch": f"SW{index}",
177
+ "PC": f"PC{index}",
178
+ "Server": f"Server{index}",
179
+ "LightWeightAccessPoint": f"AP{index}",
180
+ "WirelessRouter": f"WRT{index}",
181
+ "Tablet": f"Tablet{index}",
182
+ "Laptop": f"Laptop{index}",
183
+ "Printer": f"Printer{index}",
184
+ "Smartphone": f"Phone{index}",
185
+ }.get(device_type, f"{device_type}{index}")
186
+
187
+
188
+ def _device_kind(device: dict[str, object]) -> str:
189
+ return str(device.get("type", ""))
190
+
191
+
192
+ def _is_host_device(device: dict[str, object]) -> bool:
193
+ return _device_kind(device) in {"PC", "Server", "Printer", "Laptop"}
194
+
195
+
196
+ def _is_wireless_client_device(device: dict[str, object]) -> bool:
197
+ return _device_kind(device) in {"Tablet", "Smartphone"}
198
+
199
+
200
+ def _router_port(device: dict[str, object], index: int = 1) -> str:
201
+ model = str(device.get("model") or "")
202
+ if model.startswith("2901"):
203
+ return f"GigabitEthernet0/{index - 1}"
204
+ if model.startswith("ISR"):
205
+ return f"GigabitEthernet0/0/{index - 1}"
206
+ return f"FastEthernet0/{index - 1}"
207
+
208
+
209
+ def _switch_uplink_port(device: dict[str, object], index: int) -> str:
210
+ model = str(device.get("model") or "")
211
+ if model.startswith("3650"):
212
+ return f"GigabitEthernet1/0/{index}"
213
+ return f"GigabitEthernet0/{index}"
214
+
215
+
216
+ def _switch_access_port(index: int) -> str:
217
+ return f"FastEthernet0/{index}"
218
+
219
+
220
+ def _host_port(device: dict[str, object]) -> str:
221
+ kind = _device_kind(device)
222
+ if kind in {"Tablet", "Smartphone"}:
223
+ return "Wireless0"
224
+ return "FastEthernet0"
225
+
226
+
227
+ def _department_device_name(group_name: str, device_type: str, index: int) -> str:
228
+ suffix = {
229
+ "Switch": "SW",
230
+ "LightWeightAccessPoint": "AP",
231
+ "Printer": "PRN",
232
+ "PC": "PC",
233
+ "Tablet": "TAB",
234
+ "Laptop": "LAP",
235
+ "Server": "SRV",
236
+ "Smartphone": "PH",
237
+ }.get(device_type, device_type.upper())
238
+ return f"{group_name}-{suffix}{index}"
239
+
240
+
241
+ def _choose_switch_model(index: int, total_switches: int, uplink_intent: str | None) -> str:
242
+ if get_packet_tracer_compatibility_donor() is not None:
243
+ return "2960-24TT"
244
+ if total_switches > 1 and index == 1:
245
+ return "3650-24PS"
246
+ if uplink_intent == "gigabit" and total_switches == 1:
247
+ return "2960-24TT"
248
+ return "2960-24TT"
249
+
250
+
251
+ def _choose_router_model(plan: IntentPlan) -> str:
252
+ if get_packet_tracer_compatibility_donor() is not None:
253
+ return "ISR4331"
254
+ if plan.vlan_ids or plan.uplink_intent == "gigabit" or plan.device_requirements.get("Switch", 0):
255
+ return "2901"
256
+ return "1841"
257
+
258
+
259
+ def _append_unique_op(bucket: list[dict[str, object]], operation: dict[str, object]) -> None:
260
+ if operation not in bucket:
261
+ bucket.append(operation)
262
+
263
+
264
+ def _copy_plan(plan: IntentPlan) -> IntentPlan:
265
+ return copy.deepcopy(plan)
266
+
267
+
268
+ def _choose_topology_archetype(plan: IntentPlan) -> str:
269
+ explicit = str(plan.topology_requirements.get("uplink_topology") or "")
270
+ if explicit == "chain":
271
+ return "chain"
272
+ if plan.department_groups:
273
+ return "chain"
274
+ if plan.network_style == "small_office":
275
+ return "small_office"
276
+ if explicit == "core_switch":
277
+ return "core_access"
278
+ if plan.device_requirements.get("Switch", 0) > 1:
279
+ return "core_access"
280
+ if "wireless_ap" in plan.capabilities and plan.device_requirements.get("Switch", 0) <= 1:
281
+ return "wireless_branch"
282
+ return "star"
283
+
284
+
285
+ def _topology_tags_for_plan(plan: IntentPlan, archetype: str) -> list[str]:
286
+ tags = [archetype]
287
+ if plan.department_groups:
288
+ tags.append("department_lan")
289
+ if plan.vlan_ids and plan.device_requirements.get("Router", 0):
290
+ tags.append("router_on_a_stick")
291
+ if any(cap in plan.capabilities for cap in ["dns", "server_dns", "server_http", "server_ftp"]):
292
+ tags.append("server_services")
293
+ if any(cap in plan.capabilities for cap in ["wireless_ap", "wireless_client"]):
294
+ tags.append("wireless_edge")
295
+ if "acl" in plan.capabilities:
296
+ tags.append("acl_policy")
297
+ return sorted(dict.fromkeys(tags))
298
+
299
+
300
+ def _seed_devices_from_plan(plan: IntentPlan) -> list[dict[str, object]]:
301
+ devices = [dict(device) for device in plan.devices]
302
+ current_counts: dict[str, int] = {}
303
+ for device in devices:
304
+ dtype = _device_kind(device)
305
+ current_counts[dtype] = current_counts.get(dtype, 0) + 1
306
+
307
+ total_switches = plan.device_requirements.get("Switch", 0)
308
+ if plan.department_groups:
309
+ for index, group in enumerate(plan.department_groups, start=1):
310
+ switch_name = str(group.get("switch_name") or f"DEPT{index}-SW")
311
+ if not any(str(device.get("name")) == switch_name for device in devices):
312
+ devices.append(
313
+ {
314
+ "name": switch_name,
315
+ "type": "Switch",
316
+ "model": _choose_switch_model(index, max(total_switches, len(plan.department_groups)), plan.uplink_intent),
317
+ "group": group["name"],
318
+ "role": "department-switch",
319
+ }
320
+ )
321
+ for device_type, count in dict(group.get("devices") or {}).items():
322
+ for inner_index in range(1, int(count) + 1):
323
+ name = _department_device_name(str(group["name"]), device_type, inner_index)
324
+ if any(str(device.get("name")) == name for device in devices):
325
+ continue
326
+ entry: dict[str, object] = {"name": name, "type": device_type, "group": group["name"]}
327
+ if device_type == "LightWeightAccessPoint":
328
+ entry["model"] = "AccessPoint-PT" if get_packet_tracer_compatibility_donor() is not None else "LAP-PT"
329
+ devices.append(entry)
330
+ current_counts = {}
331
+ for device in devices:
332
+ dtype = _device_kind(device)
333
+ current_counts[dtype] = current_counts.get(dtype, 0) + 1
334
+
335
+ for device_type, count in plan.device_requirements.items():
336
+ existing = current_counts.get(device_type, 0)
337
+ for next_index in range(existing + 1, count + 1):
338
+ device: dict[str, object] = {
339
+ "name": _default_name_for_type(device_type, next_index),
340
+ "type": device_type,
341
+ }
342
+ if device_type == "Switch":
343
+ device["model"] = _choose_switch_model(next_index, count, plan.uplink_intent)
344
+ elif device_type == "Router":
345
+ device["model"] = _choose_router_model(plan)
346
+ devices.append(device)
347
+ current_counts[device_type] = count
348
+
349
+ archetype = _choose_topology_archetype(plan)
350
+ routers = [device for device in devices if _device_kind(device) == "Router"]
351
+ switches = [device for device in devices if _device_kind(device) == "Switch"]
352
+ hosts = [device for device in devices if _is_host_device(device)]
353
+
354
+ if archetype == "chain" and plan.department_groups:
355
+ if routers:
356
+ routers[0].setdefault("x", 180)
357
+ routers[0].setdefault("y", 120)
358
+ for index, group in enumerate(plan.department_groups):
359
+ base_x = 320 + index * 340
360
+ switch = next((device for device in switches if device.get("group") == group["name"]), None)
361
+ if switch is not None:
362
+ switch.setdefault("x", base_x)
363
+ switch.setdefault("y", 310)
364
+ group_devices = [device for device in devices if device.get("group") == group["name"] and device is not switch]
365
+ aps = [device for device in group_devices if _device_kind(device) == "LightWeightAccessPoint"]
366
+ printers = [device for device in group_devices if _device_kind(device) == "Printer"]
367
+ clients = [device for device in group_devices if _device_kind(device) in {"PC", "Tablet", "Laptop", "Smartphone", "Server"}]
368
+ for ap_index, ap in enumerate(aps):
369
+ ap.setdefault("x", base_x - 70 + ap_index * 120)
370
+ ap.setdefault("y", 120)
371
+ for printer_index, printer in enumerate(printers):
372
+ printer.setdefault("x", base_x - 80 + printer_index * 140)
373
+ printer.setdefault("y", 510)
374
+ for client_index, client in enumerate(clients):
375
+ row = client_index // 2
376
+ column = client_index % 2
377
+ client.setdefault("x", base_x - 120 + column * 160)
378
+ client.setdefault("y", 660 + row * 155)
379
+ else:
380
+ if routers:
381
+ routers[0].setdefault("x", 520)
382
+ routers[0].setdefault("y", 110)
383
+ for index, router in enumerate(routers[1:], start=1):
384
+ router.setdefault("x", 200 + index * 160)
385
+ router.setdefault("y", 110)
386
+ if switches:
387
+ switches[0].setdefault("x", 520)
388
+ switches[0].setdefault("y", 260)
389
+ for index, switch in enumerate(switches[1:], start=1):
390
+ switch.setdefault("x", 220 + (index - 1) * 220)
391
+ switch.setdefault("y", 420)
392
+ for index, host in enumerate(hosts):
393
+ host.setdefault("x", 180 + (index % 6) * 150)
394
+ host.setdefault("y", 610 + (index // 6) * 120)
395
+ for index, device in enumerate(devices):
396
+ device.setdefault("x", 200 + (index % 5) * 150)
397
+ device.setdefault("y", 180 + (index // 5) * 130)
398
+ return devices
399
+
400
+
401
+ def _plan_configs(plan: IntentPlan, devices: list[dict[str, object]]) -> dict[str, object]:
402
+ configs: dict[str, object] = {}
403
+ if plan.topology_requirements.get("needs_dhcp_pool"):
404
+ for device in devices:
405
+ if _device_kind(device) == "Router":
406
+ port = _router_port(device, 1)
407
+ configs[device["name"]] = [
408
+ f"hostname {device['name']}",
409
+ f"interface {port}",
410
+ " ip address 192.168.1.1 255.255.255.0",
411
+ " no shutdown",
412
+ "ip dhcp pool AUTOPOOL",
413
+ " network 192.168.1.0 255.255.255.0",
414
+ " default-router 192.168.1.1",
415
+ "end",
416
+ ]
417
+ break
418
+ return configs
419
+
420
+
421
+ def _synthesize_links(plan: IntentPlan, devices: list[dict[str, object]]) -> list[dict[str, object]]:
422
+ if plan.links:
423
+ return list(plan.links)
424
+
425
+ archetype = _choose_topology_archetype(plan)
426
+ routers = [device for device in devices if _device_kind(device) == "Router"]
427
+ switches = [device for device in devices if _device_kind(device) == "Switch"]
428
+ hosts = [device for device in devices if _is_host_device(device)]
429
+ if not switches:
430
+ return []
431
+
432
+ if archetype == "chain":
433
+ links: list[dict[str, object]] = []
434
+ router = routers[0] if routers else None
435
+ ordered_switches = switches
436
+ if plan.department_groups:
437
+ ordered_switches = []
438
+ for group in plan.department_groups:
439
+ switch = next((device for device in switches if device.get("group") == group["name"]), None)
440
+ if switch is not None:
441
+ ordered_switches.append(switch)
442
+ if router and ordered_switches:
443
+ links.append(
444
+ {
445
+ "a": {"dev": ordered_switches[0]["name"], "port": _switch_uplink_port(ordered_switches[0], 1)},
446
+ "b": {"dev": router["name"], "port": _router_port(router, 1)},
447
+ "media": "straight-through",
448
+ }
449
+ )
450
+ for index in range(len(ordered_switches) - 1):
451
+ links.append(
452
+ {
453
+ "a": {"dev": ordered_switches[index]["name"], "port": _switch_uplink_port(ordered_switches[index], 2)},
454
+ "b": {"dev": ordered_switches[index + 1]["name"], "port": _switch_uplink_port(ordered_switches[index + 1], 1)},
455
+ "media": "straight-through",
456
+ }
457
+ )
458
+ access_port_index: dict[str, int] = {str(device["name"]): 1 for device in ordered_switches}
459
+ for device in devices:
460
+ if _is_wireless_client_device(device):
461
+ continue
462
+ if _device_kind(device) not in {"PC", "Server", "Printer", "Laptop", "LightWeightAccessPoint"}:
463
+ continue
464
+ group_name = str(device.get("group") or "")
465
+ switch = next((item for item in ordered_switches if str(item.get("group") or "") == group_name), ordered_switches[0] if ordered_switches else None)
466
+ if switch is None:
467
+ continue
468
+ switch_name = str(switch["name"])
469
+ port_index = access_port_index[switch_name]
470
+ access_port_index[switch_name] += 1
471
+ links.append(
472
+ {
473
+ "a": {"dev": device["name"], "port": _host_port(device)},
474
+ "b": {"dev": switch_name, "port": _switch_access_port(port_index)},
475
+ "media": "straight-through",
476
+ }
477
+ )
478
+ return links
479
+
480
+ core_switch = switches[0]
481
+ access_switches = switches[1:] or [core_switch]
482
+ links: list[dict[str, object]] = []
483
+ uplink_media = "straight-through"
484
+
485
+ if routers:
486
+ router = routers[0]
487
+ links.append(
488
+ {
489
+ "a": {"dev": core_switch["name"], "port": _switch_uplink_port(core_switch, len(access_switches) + 1 if switches[1:] else 1)},
490
+ "b": {"dev": router["name"], "port": _router_port(router, 1)},
491
+ "media": uplink_media,
492
+ }
493
+ )
494
+
495
+ if switches[1:]:
496
+ for index, switch in enumerate(switches[1:], start=1):
497
+ links.append(
498
+ {
499
+ "a": {"dev": core_switch["name"], "port": _switch_uplink_port(core_switch, index)},
500
+ "b": {"dev": switch["name"], "port": _switch_uplink_port(switch, 1)},
501
+ "media": uplink_media,
502
+ }
503
+ )
504
+
505
+ host_port_index: dict[str, int] = {str(device["name"]): 1 for device in access_switches}
506
+ for index, host in enumerate(hosts):
507
+ target_switch = access_switches[index % len(access_switches)]
508
+ switch_name = str(target_switch["name"])
509
+ access_index = host_port_index[switch_name]
510
+ host_port_index[switch_name] += 1
511
+ links.append(
512
+ {
513
+ "a": {"dev": host["name"], "port": _host_port(host)},
514
+ "b": {"dev": switch_name, "port": _switch_access_port(access_index)},
515
+ "media": "straight-through",
516
+ }
517
+ )
518
+ return links
519
+
520
+
521
+ def _synthesize_vlan_and_link_ops(plan: IntentPlan, devices: list[dict[str, object]], links: list[dict[str, object]]) -> None:
522
+ if not plan.vlan_ids:
523
+ return
524
+
525
+ switches = [device for device in devices if _device_kind(device) == "Switch"]
526
+ routers = [device for device in devices if _device_kind(device) == "Router"]
527
+ allowed = list(plan.vlan_ids)
528
+ core_switch = switches[0] if switches else None
529
+
530
+ for switch in switches:
531
+ for vlan_id in plan.vlan_ids:
532
+ _append_unique_op(plan.switch_ops, {"op": "set_vlan", "device": switch["name"], "vlan": vlan_id, "name": f"VLAN{vlan_id}"})
533
+
534
+ if core_switch is not None:
535
+ switch_names = {str(switch["name"]) for switch in switches}
536
+ for link in links:
537
+ left_name = str(link["a"]["dev"])
538
+ right_name = str(link["b"]["dev"])
539
+ left_port = str(link["a"]["port"])
540
+ right_port = str(link["b"]["port"])
541
+ if {left_name, right_name} & switch_names:
542
+ if "GigabitEthernet" in left_port:
543
+ if left_name in switch_names:
544
+ _append_unique_op(plan.switch_ops, {"op": "set_trunk_port", "device": left_name, "port": left_port, "allowed": allowed, "native": None})
545
+ if "GigabitEthernet" in right_port:
546
+ if right_name in switch_names:
547
+ _append_unique_op(plan.switch_ops, {"op": "set_trunk_port", "device": right_name, "port": right_port, "allowed": allowed, "native": None})
548
+
549
+ if routers:
550
+ router = routers[0]
551
+ base_port = _router_port(router, 1)
552
+ for vlan_id in plan.vlan_ids:
553
+ _append_unique_op(
554
+ plan.router_ops,
555
+ {
556
+ "op": "set_subinterface",
557
+ "device": router["name"],
558
+ "subinterface": f"{base_port}.{vlan_id}",
559
+ "vlan": vlan_id,
560
+ "ip": f"192.168.{vlan_id}.1",
561
+ "prefix": 24,
562
+ },
563
+ )
564
+ if plan.topology_requirements.get("needs_dhcp_pool"):
565
+ _append_unique_op(
566
+ plan.router_ops,
567
+ {
568
+ "op": "set_router_dhcp_pool",
569
+ "device": router["name"],
570
+ "name": f"VLAN{vlan_id}",
571
+ "network": f"192.168.{vlan_id}.0",
572
+ "prefix": 24,
573
+ "gateway": f"192.168.{vlan_id}.1",
574
+ "dns": None,
575
+ "start": f"192.168.{vlan_id}.100",
576
+ "max_users": 100,
577
+ },
578
+ )
579
+
580
+ if plan.host_vlan_assignment and not plan.department_groups:
581
+ access_port_links = [link for link in links if "FastEthernet0/" in str(link["b"]["port"]) and _device_kind(next(device for device in devices if device["name"] == link["b"]["dev"])) == "Switch"]
582
+ vlan_queue: list[int] = []
583
+ for vlan_id, count in sorted(plan.host_vlan_assignment.items()):
584
+ vlan_queue.extend([vlan_id] * count)
585
+ for link, vlan_id in zip(access_port_links, vlan_queue):
586
+ _append_unique_op(
587
+ plan.switch_ops,
588
+ {
589
+ "op": "set_access_port",
590
+ "device": str(link["b"]["dev"]),
591
+ "port": str(link["b"]["port"]),
592
+ "vlan": vlan_id,
593
+ },
594
+ )
595
+ if plan.department_groups:
596
+ switch_by_group = {str(device.get("group") or ""): str(device["name"]) for device in switches if device.get("group")}
597
+ for group in plan.department_groups:
598
+ vlan_id = group.get("vlan_id")
599
+ switch_name = switch_by_group.get(str(group["name"]))
600
+ if not vlan_id or not switch_name:
601
+ continue
602
+ for link in links:
603
+ if str(link["b"]["dev"]) != switch_name or "FastEthernet0/" not in str(link["b"]["port"]):
604
+ continue
605
+ if str(link["a"]["dev"]).startswith(str(group["name"])):
606
+ _append_unique_op(
607
+ plan.switch_ops,
608
+ {
609
+ "op": "set_access_port",
610
+ "device": switch_name,
611
+ "port": str(link["b"]["port"]),
612
+ "vlan": int(vlan_id),
613
+ },
614
+ )
615
+
616
+
617
+ def _build_topology_plan(plan: IntentPlan, devices: list[dict[str, object]], links: list[dict[str, object]]) -> TopologyPlan:
618
+ archetype = _choose_topology_archetype(plan)
619
+ layout = {str(device["name"]): {"x": int(device.get("x", 0)), "y": int(device.get("y", 0))} for device in devices}
620
+ port_map: dict[str, list[str]] = {}
621
+ for link in links:
622
+ for endpoint in ["a", "b"]:
623
+ device_name = str(link[endpoint]["dev"])
624
+ port_map.setdefault(device_name, []).append(str(link[endpoint]["port"]))
625
+ return TopologyPlan(
626
+ topology_archetype=archetype,
627
+ devices=devices,
628
+ links=links,
629
+ layout=layout,
630
+ port_map=port_map,
631
+ )
632
+
633
+
634
+ def _build_config_plan(plan: IntentPlan) -> ConfigPlan:
635
+ return ConfigPlan(
636
+ switch_ops=plan.switch_ops,
637
+ router_ops=plan.router_ops,
638
+ server_ops=plan.server_ops,
639
+ wireless_ops=plan.wireless_ops,
640
+ end_device_ops=plan.end_device_ops,
641
+ management_ops=plan.management_ops,
642
+ assumptions_used=plan.assumptions_used,
643
+ )
644
+
645
+
646
+ def _name_sort_key(name: str) -> tuple[object, ...]:
647
+ parts = re.split(r"(\d+)", name)
648
+ key: list[object] = []
649
+ for part in parts:
650
+ if not part:
651
+ continue
652
+ key.append(int(part) if part.isdigit() else part.lower())
653
+ return tuple(key)
654
+
655
+
656
+ def _donor_group_prefix(name: str, device_type: str) -> str | None:
657
+ if device_type == "Switch":
658
+ for suffix in ["-SWITCH", "-SW"]:
659
+ if name.upper().endswith(suffix):
660
+ return name[: -len(suffix)]
661
+ if "-" in name and device_type not in {"Router", "Power Distribution Device"}:
662
+ return name.split("-", 1)[0]
663
+ return None
664
+
665
+
666
+ def _collect_donor_groups(root: ET.Element) -> list[dict[str, object]]:
667
+ devices = inventory_devices(root)
668
+ groups: list[dict[str, object]] = []
669
+ for device in devices:
670
+ if device["type"] != "Switch":
671
+ continue
672
+ prefix = _donor_group_prefix(device["name"], device["type"])
673
+ if not prefix:
674
+ continue
675
+ members = [
676
+ candidate
677
+ for candidate in devices
678
+ if candidate["name"] != device["name"]
679
+ and _donor_group_prefix(candidate["name"], candidate["type"]) == prefix
680
+ ]
681
+ members_by_type: dict[str, list[dict[str, str]]] = {}
682
+ for member in members:
683
+ members_by_type.setdefault(member["type"], []).append(member)
684
+ for bucket in members_by_type.values():
685
+ bucket.sort(key=lambda item: _name_sort_key(item["name"]))
686
+ groups.append(
687
+ {
688
+ "group_name": prefix,
689
+ "switch": device,
690
+ "members": members,
691
+ "members_by_type": members_by_type,
692
+ }
693
+ )
694
+ return groups
695
+
696
+
697
+ def _target_groups_from_blueprint(plan: IntentPlan, blueprint: dict[str, object]) -> list[dict[str, object]]:
698
+ devices = [dict(device) for device in blueprint.get("devices", [])]
699
+ links = [dict(link) for link in blueprint.get("links", [])]
700
+ switches = [device for device in devices if _device_kind(device) == "Switch"]
701
+ if plan.department_groups:
702
+ result: list[dict[str, object]] = []
703
+ for group in plan.department_groups:
704
+ group_name = str(group["name"])
705
+ switch = next((device for device in switches if str(device.get("group") or "") == group_name), None)
706
+ if switch is None:
707
+ continue
708
+ members = [device for device in devices if str(device.get("group") or "") == group_name and _device_kind(device) != "Switch"]
709
+ result.append({"group_name": group_name, "switch": switch, "members": members})
710
+ return result
711
+ groups: list[dict[str, object]] = []
712
+ by_name = {str(device["name"]): device for device in devices}
713
+ switch_map = {str(device["name"]): {"group_name": str(device["name"]), "switch": device, "members": []} for device in switches}
714
+ host_assignment: dict[str, str] = {}
715
+ for link in links:
716
+ left_name = str(link["a"]["dev"])
717
+ right_name = str(link["b"]["dev"])
718
+ left_type = _device_kind(by_name.get(left_name, {}))
719
+ right_type = _device_kind(by_name.get(right_name, {}))
720
+ if left_type == "Switch" and right_type != "Switch":
721
+ host_assignment[right_name] = left_name
722
+ elif right_type == "Switch" and left_type != "Switch":
723
+ host_assignment[left_name] = right_name
724
+ switch_names = list(switch_map)
725
+ fallback_index = 0
726
+ for device in devices:
727
+ if _device_kind(device) == "Switch":
728
+ continue
729
+ assigned_switch = host_assignment.get(str(device["name"]))
730
+ if assigned_switch is None and switch_names:
731
+ assigned_switch = switch_names[fallback_index % len(switch_names)]
732
+ fallback_index += 1
733
+ if assigned_switch and assigned_switch in switch_map:
734
+ switch_map[assigned_switch]["members"].append(device)
735
+ for switch in switches:
736
+ groups.append(switch_map[str(switch["name"])])
737
+ return groups
738
+
739
+
740
+ def _donor_capacity(root: ET.Element, donor_groups: list[dict[str, object]]) -> dict[str, object]:
741
+ counts: dict[str, int] = {}
742
+ for device in inventory_devices(root):
743
+ counts[device["type"]] = counts.get(device["type"], 0) + 1
744
+ group_counts: list[dict[str, object]] = []
745
+ for group in donor_groups:
746
+ member_counts: dict[str, int] = {}
747
+ for member in group["members"]:
748
+ member_counts[member["type"]] = member_counts.get(member["type"], 0) + 1
749
+ group_counts.append(
750
+ {
751
+ "group_name": group["group_name"],
752
+ "switch": group["switch"]["name"],
753
+ "members": member_counts,
754
+ }
755
+ )
756
+ return {"device_counts": counts, "group_count": len(donor_groups), "groups": group_counts}
757
+
758
+
759
+ def _sanitize_runtime_sections(root: ET.Element) -> None:
760
+ scenario_set = root.find("./SCENARIOSET")
761
+ if scenario_set is not None:
762
+ scenario_set.clear()
763
+ scenario = ET.SubElement(scenario_set, "SCENARIO")
764
+ name = ET.SubElement(scenario, "NAME")
765
+ name.set("translate", "true")
766
+ name.text = "Scenario 0"
767
+ description = ET.SubElement(scenario, "DESCRIPTION")
768
+ description.set("translate", "true")
769
+ command_logs = root.find("./COMMAND_LOGS")
770
+ if command_logs is not None:
771
+ command_logs.clear()
772
+ ceps = root.find("./CEPS")
773
+ if ceps is not None:
774
+ ceps.clear()
775
+
776
+
777
+ def _unexpected_workspace_issues(donor_root: ET.Element, generated_root: ET.Element) -> list[str]:
778
+ donor_result = inspect_workspace_integrity(donor_root)
779
+ generated_result = inspect_workspace_integrity(generated_root)
780
+ donor_issue_set = set(donor_result.blocking_issues)
781
+ return [issue for issue in generated_result.blocking_issues if issue not in donor_issue_set]
782
+
783
+
784
+ def _build_donor_prune_plan(plan: IntentPlan, blueprint: dict[str, object]) -> tuple[IntentPlan, DonorArchetypePlan]:
785
+ compat_donor, _ = _compat_donor_details()
786
+ if compat_donor is None:
787
+ raise PlanningError("Prompt plan is incomplete; generation was skipped.", plan)
788
+ donor_root = decode_pkt_to_root(compat_donor)
789
+ donor_groups = _collect_donor_groups(donor_root)
790
+ target_groups = _target_groups_from_blueprint(plan, blueprint)
791
+ adapted_plan = copy.deepcopy(plan)
792
+ adapted_plan.edit_operations = []
793
+ donor_devices = inventory_devices(donor_root)
794
+ donor_links = inventory_links(donor_root)
795
+ donor_capacity = _donor_capacity(donor_root, donor_groups)
796
+ if len(target_groups) > len(donor_groups):
797
+ gap = f"Compatibility donor supports only {len(donor_groups)} switch groups; requested {len(target_groups)}."
798
+ if gap not in adapted_plan.blocking_gaps:
799
+ adapted_plan.blocking_gaps.append(gap)
800
+ raise PlanningError("Prompt plan is incomplete; generation was skipped.", adapted_plan)
801
+
802
+ kept_devices: set[str] = set()
803
+ parked_devices: list[str] = []
804
+ renamed_devices: list[dict[str, str]] = []
805
+ mutation_groups: list[dict[str, object]] = []
806
+ rename_map: dict[str, str] = {}
807
+
808
+ def keep_name(old_name: str, new_name: str | None = None, x: int | None = None, y: int | None = None) -> None:
809
+ kept_devices.add(old_name)
810
+ final_name = new_name or old_name
811
+ rename_map[old_name] = final_name
812
+ if old_name != final_name:
813
+ adapted_plan.edit_operations.append({"op": "rename_device", "device": old_name, "new_name": final_name})
814
+ renamed_devices.append({"from": old_name, "to": final_name})
815
+ if x is not None and y is not None:
816
+ adapted_plan.edit_operations.append({"op": "reflow_layout", "device": final_name, "x": int(x), "y": int(y)})
817
+
818
+ park_cursor = {"index": 0}
819
+
820
+ def park_device(
821
+ old_name: str,
822
+ anchor_x: int | None = None,
823
+ anchor_y: int | None = None,
824
+ local_index: int | None = None,
825
+ parked_name: str | None = None,
826
+ ) -> None:
827
+ if old_name in kept_devices:
828
+ return
829
+ kept_devices.add(old_name)
830
+ final_name = parked_name or old_name
831
+ rename_map[old_name] = final_name
832
+ if old_name != final_name:
833
+ adapted_plan.edit_operations.append({"op": "rename_device", "device": old_name, "new_name": final_name})
834
+ renamed_devices.append({"from": old_name, "to": final_name})
835
+ if local_index is None:
836
+ park_index = park_cursor["index"]
837
+ park_cursor["index"] += 1
838
+ else:
839
+ park_index = local_index
840
+ parked_devices.append(final_name)
841
+ if anchor_x is None:
842
+ anchor_x = 1820
843
+ if anchor_y is None:
844
+ anchor_y = 180
845
+ col = park_index % 3
846
+ row = park_index // 3
847
+ adapted_plan.edit_operations.append(
848
+ {
849
+ "op": "reflow_layout",
850
+ "device": final_name,
851
+ "x": int(anchor_x + (-130 + col * 130)),
852
+ "y": int(anchor_y + row * 115),
853
+ }
854
+ )
855
+
856
+ target_router = next((device for device in blueprint.get("devices", []) if _device_kind(device) == "Router"), None)
857
+ donor_router = next((device for device in donor_devices if device["type"] == "Router"), None)
858
+ if target_router is not None:
859
+ if donor_router is None:
860
+ gap = "Compatibility donor does not contain a router prototype for prompt generation."
861
+ if gap not in adapted_plan.blocking_gaps:
862
+ adapted_plan.blocking_gaps.append(gap)
863
+ raise PlanningError("Prompt plan is incomplete; generation was skipped.", adapted_plan)
864
+ keep_name(str(donor_router["name"]), str(target_router["name"]), int(target_router.get("x", 0)), int(target_router.get("y", 0)))
865
+ elif donor_router is not None:
866
+ park_device(str(donor_router["name"]))
867
+
868
+ for donor_group, target_group in zip(donor_groups, target_groups):
869
+ group_kept: list[str] = []
870
+ group_park_index = 0
871
+ donor_switch = donor_group["switch"]
872
+ target_switch = target_group["switch"]
873
+ switch_x = int(target_switch.get("x", 0))
874
+ switch_y = int(target_switch.get("y", 0))
875
+ park_anchor_x = switch_x
876
+ park_anchor_y = switch_y + 650
877
+ keep_name(str(donor_switch["name"]), str(target_switch["name"]), int(target_switch.get("x", 0)), int(target_switch.get("y", 0)))
878
+ group_kept.append(str(target_switch["name"]))
879
+ target_members_by_type: dict[str, list[dict[str, object]]] = {}
880
+ for member in target_group["members"]:
881
+ target_members_by_type.setdefault(_device_kind(member), []).append(member)
882
+ for members in target_members_by_type.values():
883
+ members.sort(key=lambda item: _name_sort_key(str(item["name"])))
884
+ donor_members_by_type = donor_group["members_by_type"]
885
+ for device_type, wanted in target_members_by_type.items():
886
+ available = donor_members_by_type.get(device_type, [])
887
+ if len(wanted) > len(available):
888
+ gap = (
889
+ f"Compatibility donor group {donor_group['group_name']} has only {len(available)} {device_type} device(s); "
890
+ f"requested {len(wanted)} for {target_group['group_name']}."
891
+ )
892
+ if gap not in adapted_plan.blocking_gaps:
893
+ adapted_plan.blocking_gaps.append(gap)
894
+ raise PlanningError("Prompt plan is incomplete; generation was skipped.", adapted_plan)
895
+ for donor_member, target_member in zip(available, wanted):
896
+ keep_name(
897
+ str(donor_member["name"]),
898
+ str(target_member["name"]),
899
+ int(target_member.get("x", 0)),
900
+ int(target_member.get("y", 0)),
901
+ )
902
+ group_kept.append(str(target_member["name"]))
903
+ for spare_offset, donor_member in enumerate(available[len(wanted) :], start=1):
904
+ spare_name = f"{target_group['group_name']}-SPARE-{device_type.upper()}{spare_offset}"
905
+ park_device(str(donor_member["name"]), park_anchor_x, park_anchor_y, group_park_index, spare_name)
906
+ group_park_index += 1
907
+ for device_type, available in donor_members_by_type.items():
908
+ if device_type in target_members_by_type:
909
+ continue
910
+ for spare_offset, donor_member in enumerate(available, start=1):
911
+ spare_name = f"{target_group['group_name']}-SPARE-{device_type.upper()}{spare_offset}"
912
+ park_device(str(donor_member["name"]), park_anchor_x, park_anchor_y, group_park_index, spare_name)
913
+ group_park_index += 1
914
+ mutation_groups.append(
915
+ {
916
+ "donor_group": donor_group["group_name"],
917
+ "target_group": target_group["group_name"],
918
+ "kept_devices": group_kept,
919
+ }
920
+ )
921
+
922
+ for donor_group in donor_groups[len(target_groups) :]:
923
+ names = [str(donor_group["switch"]["name"]), *[str(member["name"]) for member in donor_group["members"]]]
924
+ donor_switch = donor_group["switch"]
925
+ switch_x = int(donor_switch.get("x", 0))
926
+ switch_y = int(donor_switch.get("y", 0))
927
+ group_park_index = 0
928
+ for name in names:
929
+ park_device(name, switch_x, switch_y + 650, group_park_index)
930
+ group_park_index += 1
931
+ mutation_groups.append({"donor_group": donor_group["group_name"], "target_group": None, "parked_devices": names})
932
+
933
+ archetype_plan = DonorArchetypePlan(
934
+ compat_donor=str(compat_donor),
935
+ donor_capacity=donor_capacity,
936
+ kept_devices=sorted(rename_map.values(), key=_name_sort_key),
937
+ pruned_devices=sorted(dict.fromkeys(parked_devices), key=_name_sort_key),
938
+ renamed_devices=renamed_devices,
939
+ mutation_groups=mutation_groups,
940
+ layout_strategy="donor_preserve_park_unused",
941
+ )
942
+ return adapted_plan, archetype_plan
943
+
944
+
945
+ def prepare_generation_plan(plan: IntentPlan) -> IntentPlan:
946
+ enriched = _copy_plan(plan)
947
+ if enriched.goal == "edit":
948
+ return enriched
949
+
950
+ if enriched.department_groups and not enriched.device_requirements.get("Router", 0):
951
+ enriched.device_requirements["Router"] = 1
952
+ enriched.assumptions_used.append("Added one router for department-based topology.")
953
+ if enriched.department_groups and not enriched.vlan_ids:
954
+ enriched.vlan_ids = [10 * (index + 1) for index in range(len(enriched.department_groups))]
955
+ enriched.topology_requirements["vlan_ids"] = enriched.vlan_ids
956
+ for index, group in enumerate(enriched.department_groups):
957
+ group["vlan_id"] = enriched.vlan_ids[index]
958
+ pc_count = int(group.get("devices", {}).get("PC", 0))
959
+ if pc_count:
960
+ enriched.host_vlan_assignment[enriched.vlan_ids[index]] = pc_count
961
+ enriched.assumptions_used.append("Generated default VLAN IDs in 10-step increments for each department.")
962
+ if enriched.device_requirements.get("Switch", 0) > 1:
963
+ enriched.topology_requirements.setdefault("uplink_topology", "core_switch")
964
+ if enriched.department_groups:
965
+ enriched.topology_requirements["uplink_topology"] = "chain"
966
+ if enriched.device_requirements.get("Switch", 0) and not enriched.host_link_intent and enriched.device_requirements.get("PC", 0):
967
+ enriched.host_link_intent = "fastethernet"
968
+ enriched.topology_requirements.setdefault("host_link_intent", "fastethernet")
969
+ enriched.assumptions_used.append("Defaulted host links to FastEthernet.")
970
+ if enriched.department_groups and any(
971
+ any(device_type in {"Tablet", "Smartphone"} for device_type in dict(group.get("devices") or {}))
972
+ for group in enriched.department_groups
973
+ ):
974
+ assumption = "Tablets and smartphones are treated as wireless clients and are not auto-wired."
975
+ if assumption not in enriched.assumptions_used:
976
+ enriched.assumptions_used.append(assumption)
977
+ if enriched.device_requirements.get("Switch", 0) > 1 and not enriched.uplink_intent:
978
+ enriched.uplink_intent = "gigabit"
979
+ enriched.topology_requirements.setdefault("uplink_intent", "gigabit")
980
+ enriched.assumptions_used.append("Defaulted switch uplinks to GigabitEthernet.")
981
+
982
+ if enriched.vlan_ids and enriched.device_requirements.get("PC", 0) and not enriched.host_vlan_assignment and not any(op["op"] == "set_access_port" for op in enriched.switch_ops):
983
+ gap = "Host-to-VLAN assignment is missing. Specify how many PCs belong to each VLAN."
984
+ if gap not in enriched.blocking_gaps:
985
+ enriched.blocking_gaps.append(gap)
986
+
987
+ if any(cap in enriched.capabilities for cap in ["vlan", "trunk"]) or enriched.vlan_ids:
988
+ for capability in ["vlan", "trunk", "access_port"]:
989
+ if capability not in enriched.capabilities:
990
+ enriched.capabilities.append(capability)
991
+ if enriched.vlan_ids and enriched.device_requirements.get("Router", 0):
992
+ for capability in ["router_on_a_stick"]:
993
+ if capability not in enriched.capabilities:
994
+ enriched.capabilities.append(capability)
995
+
996
+ return enriched
997
+
998
+
999
+ def build_prompt_blueprint(plan: IntentPlan) -> tuple[dict[str, object], IntentPlan]:
1000
+ prepared = _apply_prompt_compatibility_requirements(plan)
1001
+ if prepared.blocking_gaps:
1002
+ raise PlanningError("Prompt plan is incomplete; generation was skipped.", prepared)
1003
+
1004
+ devices = _seed_devices_from_plan(prepared)
1005
+ links = _synthesize_links(prepared, devices)
1006
+ prepared.links = links
1007
+ _synthesize_vlan_and_link_ops(prepared, devices, links)
1008
+ prepared.capabilities = sorted(dict.fromkeys(prepared.capabilities))
1009
+ topology_plan = _build_topology_plan(prepared, devices, links)
1010
+ config_plan = _build_config_plan(prepared)
1011
+
1012
+ blueprint = {
1013
+ "name": "Generated from prompt",
1014
+ "capabilities": prepared.capabilities,
1015
+ "devices": devices,
1016
+ "links": links,
1017
+ "configs": _plan_configs(prepared, devices),
1018
+ "topology_archetype": topology_plan.topology_archetype,
1019
+ "topology_plan": asdict(topology_plan),
1020
+ "config_plan": asdict(config_plan),
1021
+ "workspace_mode": "logical_only_safe",
1022
+ }
1023
+ return blueprint, prepared
1024
+
1025
+
1026
+ def generate_from_blueprint(blueprint_path: Path, output_path: Path, xml_out_path: Path | None = None) -> None:
1027
+ blueprint = json.loads(blueprint_path.read_text(encoding="utf-8"))
1028
+ xml_bytes = build_packet_tracer_xml(blueprint)
1029
+ if xml_out_path is not None:
1030
+ xml_out_path.parent.mkdir(parents=True, exist_ok=True)
1031
+ xml_out_path.write_bytes(xml_bytes)
1032
+ pkt_bytes = encode_pkt_modern(xml_bytes)
1033
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1034
+ output_path.write_bytes(pkt_bytes)
1035
+ print(f"PKT file created: {output_path}")
1036
+ print(f"XML bytes: {len(xml_bytes)}")
1037
+ print(f"PKT bytes: {len(pkt_bytes)}")
1038
+
1039
+
1040
+ def generate_from_prompt(prompt: str, output_path: Path, xml_out_path: Path | None = None, reference_roots: list[Path] | None = None) -> None:
1041
+ raw_plan = parse_intent(prompt)
1042
+ if raw_plan.goal == "edit" and raw_plan.pkt_path:
1043
+ edit_pkt_file(raw_plan.pkt_path, raw_plan, output_path, xml_out_path)
1044
+ print(f"Edited PKT file created: {output_path}")
1045
+ return
1046
+
1047
+ blueprint, prepared_plan = build_prompt_blueprint(raw_plan)
1048
+ adapted_plan, donor_archetype = _build_donor_prune_plan(prepared_plan, blueprint)
1049
+ donor_root = decode_pkt_to_root(donor_archetype.compat_donor)
1050
+ root = apply_plan_operations(donor_root, adapted_plan)
1051
+ _sanitize_runtime_sections(root)
1052
+ unexpected_workspace_issues = _unexpected_workspace_issues(donor_root, root)
1053
+ if unexpected_workspace_issues:
1054
+ raise ValueError("; ".join(unexpected_workspace_issues))
1055
+ validate_donor_coherence(donor_root, root)
1056
+ xml_bytes = ET.tostring(root, encoding="utf-8", xml_declaration=False)
1057
+ if xml_out_path is not None:
1058
+ xml_out_path.parent.mkdir(parents=True, exist_ok=True)
1059
+ xml_out_path.write_bytes(xml_bytes)
1060
+ pkt_bytes = encode_pkt_modern(xml_bytes)
1061
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1062
+ output_path.write_bytes(pkt_bytes)
1063
+ print(f"Selected donor: {donor_archetype.compat_donor}")
1064
+ compat_donor, compat_donor_version = _compat_donor_details()
1065
+ if compat_donor is not None:
1066
+ print(f"Compatibility donor: {compat_donor} ({compat_donor_version or 'unknown'})")
1067
+ if reference_roots:
1068
+ references = load_reference_catalog(reference_roots)
1069
+ print(f"Loaded reference-only samples: {len(references)}")
1070
+ print(f"PKT file created: {output_path}")
1071
+
1072
+
1073
+ def explain_plan(prompt: str, reference_roots: list[Path] | None = None) -> None:
1074
+ plan = _apply_prompt_compatibility_requirements(parse_intent(prompt))
1075
+ donor_details = inspect_packet_tracer_compatibility_donor()
1076
+ compat_donor, compat_donor_version = donor_details.resolved_path, donor_details.donor_version
1077
+ result: dict[str, object] = {
1078
+ "intent_plan": plan.to_dict(),
1079
+ "compatibility_mode": "donor_prune_strict_9_0",
1080
+ "compat_donor": str(compat_donor) if compat_donor is not None else None,
1081
+ "compat_donor_version": compat_donor_version,
1082
+ "compat_donor_source": donor_details.donor_source,
1083
+ "target_version": donor_details.target_version,
1084
+ "blocking_reason": donor_details.blocking_reason or None,
1085
+ "donor_candidates": [
1086
+ {"source": source, "path": str(path)}
1087
+ for source, path in donor_details.candidate_paths[:10]
1088
+ ],
1089
+ }
1090
+ if not plan.blocking_gaps and plan.goal != "edit":
1091
+ blueprint, prepared = build_prompt_blueprint(plan)
1092
+ topology_plan = TopologyPlan(**blueprint.get("topology_plan", {}))
1093
+ config_plan = ConfigPlan(**blueprint.get("config_plan", {}))
1094
+ topology_tags = _topology_tags_for_plan(prepared, str(blueprint.get("topology_archetype", "general")))
1095
+ ranked = rank_samples(load_catalog(), prepared.capabilities, prepared.device_requirements, topology_tags=topology_tags, prototype_only=True)
1096
+ validation = _preflight_validation(prepared, topology_plan, config_plan)
1097
+ selected_donor = None
1098
+ donor_capacity = None
1099
+ prune_plan = None
1100
+ try:
1101
+ adapted_plan, donor_archetype = _build_donor_prune_plan(prepared, blueprint)
1102
+ selected_donor = donor_archetype.compat_donor
1103
+ donor_capacity = donor_archetype.donor_capacity
1104
+ prune_plan = asdict(donor_archetype)
1105
+ donor_root = decode_pkt_to_root(donor_archetype.compat_donor)
1106
+ candidate_root = apply_plan_operations(donor_root, adapted_plan)
1107
+ _sanitize_runtime_sections(candidate_root)
1108
+ workspace_result = inspect_workspace_integrity(candidate_root)
1109
+ workspace_result.blocking_issues = _unexpected_workspace_issues(donor_root, candidate_root)
1110
+ workspace_result.logical_status = "invalid" if workspace_result.blocking_issues else "ok"
1111
+ coherence_result = inspect_donor_coherence(donor_root, candidate_root)
1112
+ result["validation_report"] = {
1113
+ "workspace_mode": workspace_result.workspace_mode,
1114
+ "logical_status": workspace_result.logical_status,
1115
+ "physical_status": workspace_result.physical_status,
1116
+ "device_metadata_status": coherence_result.device_metadata_status,
1117
+ "scenario_status": coherence_result.scenario_status,
1118
+ "physical_runtime_status": coherence_result.physical_runtime_status,
1119
+ "blocking_issues": [*workspace_result.blocking_issues, *coherence_result.blocking_issues],
1120
+ }
1121
+ except PlanningError as exc:
1122
+ result["intent_plan"] = exc.plan.to_dict()
1123
+ except ValueError as exc:
1124
+ result["validation_report"] = {"status": "invalid", "blocking_issues": str(exc).split("; ")}
1125
+ result["topology_plan"] = blueprint.get("topology_plan")
1126
+ result["config_plan"] = blueprint.get("config_plan")
1127
+ result["estimate_plan"] = _estimate_plan(topology_plan, config_plan)
1128
+ result["preflight_validation"] = validation
1129
+ result["autofix_summary"] = _autofix_summary(prepared, validation)
1130
+ result["assumptions_used"] = prepared.assumptions_used
1131
+ result["workspace_mode"] = blueprint.get("workspace_mode", "logical_only_safe")
1132
+ result["selected_donor"] = selected_donor
1133
+ result["donor_capacity"] = donor_capacity
1134
+ result["prune_plan"] = prune_plan
1135
+ candidates = [
1136
+ {
1137
+ "relative_path": candidate.sample.relative_path,
1138
+ "origin": candidate.sample.origin,
1139
+ "total_score": candidate.total_score,
1140
+ "capability_score": candidate.capability_score,
1141
+ "topology_score": candidate.topology_score,
1142
+ "reasons": candidate.reasons[:8],
1143
+ }
1144
+ for candidate in ranked[:5]
1145
+ ]
1146
+ result["cisco_sample_candidates"] = candidates
1147
+ result["sample_candidates"] = candidates
1148
+ if reference_roots:
1149
+ reference_catalog = load_reference_catalog(reference_roots)
1150
+ reference_ranked = rank_reference_samples(
1151
+ reference_catalog,
1152
+ plan.capabilities,
1153
+ plan.device_requirements,
1154
+ topology_tags=_topology_tags_for_plan(plan, str(result.get("topology_plan", {}).get("topology_archetype", "general"))) if result.get("topology_plan") else None,
1155
+ )
1156
+ patterns = []
1157
+ for candidate in reference_ranked[:10]:
1158
+ pattern = ReferencePattern(
1159
+ relative_path=candidate.sample.relative_path,
1160
+ origin=candidate.sample.origin,
1161
+ capability_tags=candidate.sample.capability_tags,
1162
+ topology_tags=candidate.sample.topology_tags,
1163
+ device_summary=candidate.sample.normalized_device_counts(),
1164
+ )
1165
+ pattern_dict = asdict(pattern)
1166
+ pattern_dict["score"] = candidate.total_score
1167
+ pattern_dict["reasons"] = candidate.reasons[:8]
1168
+ patterns.append(pattern_dict)
1169
+ result["external_reference_patterns"] = patterns
1170
+ result["reference_patterns"] = patterns
1171
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1172
+
1173
+
1174
+ def inventory_pkt(pkt_path: Path) -> None:
1175
+ root = ET.fromstring(decode_pkt_modern(pkt_path.read_bytes()))
1176
+ payload = inventory_root(root)
1177
+ workspace = inspect_workspace_integrity(root)
1178
+ donor_details = inspect_packet_tracer_compatibility_donor()
1179
+ compat_donor, compat_donor_version = donor_details.resolved_path, donor_details.donor_version
1180
+ payload["workspace_validation"] = {
1181
+ "workspace_mode": workspace.workspace_mode,
1182
+ "logical_status": workspace.logical_status,
1183
+ "physical_status": workspace.physical_status,
1184
+ "blocking_issues": workspace.blocking_issues,
1185
+ }
1186
+ payload["compatibility_mode"] = "strict_9_0"
1187
+ payload["compat_donor"] = str(compat_donor) if compat_donor is not None else None
1188
+ payload["compat_donor_version"] = compat_donor_version
1189
+ payload["compat_donor_source"] = donor_details.donor_source
1190
+ payload["target_version"] = donor_details.target_version
1191
+ payload["blocking_reason"] = donor_details.blocking_reason or None
1192
+ payload["donor_candidates"] = [
1193
+ {"source": source, "path": str(path)}
1194
+ for source, path in donor_details.candidate_paths[:10]
1195
+ ]
1196
+ payload["pkt_version"] = root.findtext("./VERSION")
1197
+ if compat_donor is not None:
1198
+ donor_root = decode_pkt_to_root(compat_donor)
1199
+ coherence = inspect_donor_coherence(donor_root, root)
1200
+ payload["validation_report"] = {
1201
+ "device_metadata_status": coherence.device_metadata_status,
1202
+ "scenario_status": coherence.scenario_status,
1203
+ "physical_runtime_status": coherence.physical_runtime_status,
1204
+ "blocking_issues": coherence.blocking_issues,
1205
+ }
1206
+ payload.update(_link_schema_summary(root))
1207
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1208
+
1209
+
1210
+ def validate_open(pkt_path: Path) -> None:
1211
+ packet_tracer_exe = require_packet_tracer_exe()
1212
+ process = subprocess.Popen([str(packet_tracer_exe), str(pkt_path)])
1213
+ print(json.dumps({"status": "launched", "pid": process.pid, "pkt": str(pkt_path)}, ensure_ascii=False))
1214
+
1215
+
1216
+ def main() -> None:
1217
+ parser = argparse.ArgumentParser(description="Generate or inspect Cisco Packet Tracer 9.0 .pkt files")
1218
+ parser.add_argument("--blueprint", help="Path to the topology blueprint JSON")
1219
+ parser.add_argument("--prompt", help="Natural language topology or edit request")
1220
+ parser.add_argument("--output", help="Path to the output .pkt file")
1221
+ parser.add_argument("--xml-out", help="Optional XML output path for generated or decoded XML")
1222
+ parser.add_argument("--decode", help="Decode an existing .pkt file")
1223
+ parser.add_argument("--inventory", help="Print device/link/service inventory for an existing .pkt file")
1224
+ parser.add_argument("--explain-plan", help="Print the parsed prompt plan as JSON")
1225
+ parser.add_argument("--validate-open", help="Launch Packet Tracer with the given .pkt file")
1226
+ parser.add_argument("--compat-donor", help="Explicit Packet Tracer 9.0 donor .pkt path for strict compatibility mode")
1227
+ parser.add_argument("--reference-root", action="append", help="Optional local folder of imported external sample .pkt files")
1228
+ args = parser.parse_args()
1229
+ if args.compat_donor:
1230
+ os.environ["PACKET_TRACER_COMPAT_DONOR"] = args.compat_donor
1231
+ reference_roots = [Path(path) for path in (args.reference_root or [])]
1232
+
1233
+ if args.explain_plan:
1234
+ explain_plan(args.explain_plan, reference_roots)
1235
+ return
1236
+ if args.inventory:
1237
+ inventory_pkt(Path(args.inventory))
1238
+ return
1239
+ if args.decode:
1240
+ if not args.xml_out:
1241
+ parser.error("--decode requires --xml-out")
1242
+ decode_pkt_file(args.decode, args.xml_out)
1243
+ print(f"Decoded XML written to {args.xml_out}")
1244
+ return
1245
+ if args.validate_open:
1246
+ validate_open(Path(args.validate_open))
1247
+ return
1248
+
1249
+ if not args.output:
1250
+ parser.error("generation requires --output")
1251
+ if args.prompt:
1252
+ try:
1253
+ generate_from_prompt(args.prompt, Path(args.output), Path(args.xml_out) if args.xml_out else None, reference_roots)
1254
+ except PlanningError as exc:
1255
+ print(json.dumps(exc.to_dict(), indent=2, ensure_ascii=False))
1256
+ raise SystemExit(2) from exc
1257
+ return
1258
+ if not args.blueprint:
1259
+ parser.error("generation requires either --blueprint or --prompt")
1260
+ generate_from_blueprint(Path(args.blueprint), Path(args.output), Path(args.xml_out) if args.xml_out else None)
1261
+
1262
+
1263
+ if __name__ == "__main__":
1264
+ main()