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,385 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from functools import lru_cache
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+ import xml.etree.ElementTree as ET
9
+
10
+ from packet_tracer_env import get_packet_tracer_saves_root
11
+ from pkt_codec import decode_pkt_modern
12
+
13
+ SCRIPT_DIR = Path(__file__).resolve().parent
14
+ SKILL_ROOT = SCRIPT_DIR.parent
15
+ DEFAULT_CATALOG_JSON = SKILL_ROOT / "references" / "packettracer-sample-catalog.json"
16
+ DEFAULT_CATALOG_MD = SKILL_ROOT / "references" / "packettracer-sample-catalog.md"
17
+
18
+ CAPABILITY_KEYWORDS = {
19
+ "vlan": ["vlan", "trunk", "access"],
20
+ "trunk": ["trunk"],
21
+ "access_port": ["access"],
22
+ "dhcp_pool": ["dhcp", "reservation", "apipa"],
23
+ "router_dhcp": ["dhcp", "reservation", "apipa"],
24
+ "server_dhcp": ["dhcp", "reservation", "apipa"],
25
+ "dhcp_snooping": ["dhcp snooping", "option_82", "trusted_untrusted"],
26
+ "ospf": ["ospf"],
27
+ "eigrp": ["eigrp"],
28
+ "rip": ["rip"],
29
+ "nat": ["nat"],
30
+ "acl": ["acl", "access-list"],
31
+ "vpn": ["vpn", "ipsec", "gre"],
32
+ "wireless": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep", "5g", "bluetooth", "cellular"],
33
+ "wireless_ap": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep"],
34
+ "wireless_client": ["wireless", "wifi", "tablet", "smartphone", "laptop"],
35
+ "ntp": ["ntp"],
36
+ "dns": ["dns"],
37
+ "server_dns": ["dns"],
38
+ "server_http": ["http", "https", "websocket"],
39
+ "server_ftp": ["ftp", "tftp"],
40
+ "ftp_http_https": ["ftp", "http", "https", "websocket"],
41
+ "iot": ["iot", "sensor", "led", "arduino", "mqtt", "environment"],
42
+ "switching": ["switch", "switching", "lldp", "rep"],
43
+ "router_on_a_stick": ["router-on-a-stick", "dot1q"],
44
+ "host_server": ["server", "pc", "client", "host"],
45
+ "management_vlan": ["management", "vlan99", "telnet"],
46
+ "telnet": ["telnet", "terminal server"],
47
+ "tablet": ["tablet", "pda"],
48
+ "printer": ["printer"],
49
+ }
50
+
51
+ TYPE_NORMALIZATION = {
52
+ "pc": "PC",
53
+ "pc-pt": "PC",
54
+ "server": "Server",
55
+ "server-pt": "Server",
56
+ "router": "Router",
57
+ "switch": "Switch",
58
+ "multilayerswitch": "Switch",
59
+ "wirelessrouter": "WirelessRouter",
60
+ "lightweightaccesspoint": "LightWeightAccessPoint",
61
+ "accesspoint": "LightWeightAccessPoint",
62
+ "pda": "Tablet",
63
+ "tabletpc": "Tablet",
64
+ "tablet": "Tablet",
65
+ "laptop": "Laptop",
66
+ "printer": "Printer",
67
+ "smartphone": "Smartphone",
68
+ }
69
+
70
+
71
+ @dataclass
72
+ class SampleDescriptor:
73
+ path: str
74
+ relative_path: str
75
+ version: str
76
+ device_count: int
77
+ link_count: int
78
+ devices: list[dict[str, Any]]
79
+ links: list[dict[str, Any]]
80
+ capability_tags: list[str]
81
+ topology_tags: list[str]
82
+ preferred_roles: list[str]
83
+ trust_level: str = "trusted"
84
+ origin: str = "cisco-local"
85
+ role: str = "primary"
86
+ prototype_eligible: bool = True
87
+
88
+ def normalized_device_counts(self) -> dict[str, int]:
89
+ counts: dict[str, int] = {}
90
+ for device in self.devices:
91
+ normalized = normalize_device_type(device.get("type", ""))
92
+ if normalized:
93
+ counts[normalized] = counts.get(normalized, 0) + 1
94
+ return counts
95
+
96
+
97
+ @dataclass
98
+ class SampleCandidate:
99
+ sample: SampleDescriptor
100
+ capability_score: int
101
+ topology_score: int
102
+ total_score: int
103
+ reasons: list[str]
104
+
105
+
106
+ @dataclass
107
+ class ReferencePattern:
108
+ relative_path: str
109
+ origin: str
110
+ capability_tags: list[str]
111
+ topology_tags: list[str]
112
+ device_summary: dict[str, int]
113
+
114
+
115
+ def normalize_device_type(raw_type: str) -> str:
116
+ key = raw_type.strip().lower().replace(" ", "")
117
+ return TYPE_NORMALIZATION.get(key, raw_type.strip())
118
+
119
+
120
+ def _normalized_counts_for_item(item: dict[str, Any]) -> dict[str, int]:
121
+ counts: dict[str, int] = {}
122
+ for device in item.get("devices", []):
123
+ normalized = normalize_device_type(device.get("type", ""))
124
+ if normalized:
125
+ counts[normalized] = counts.get(normalized, 0) + 1
126
+ return counts
127
+
128
+
129
+ def infer_capability_tags(item: dict[str, Any]) -> list[str]:
130
+ tags: set[str] = set()
131
+ rel = item.get("relative_path", "").lower().replace("\\", "/")
132
+ rel_flat = rel.replace("/", " ")
133
+ devices = item.get("devices", [])
134
+ normalized_types = [normalize_device_type(device.get("type", "")) for device in devices]
135
+ router_count = normalized_types.count("Router")
136
+ switch_count = normalized_types.count("Switch")
137
+ host_count = sum(1 for dtype in normalized_types if dtype in {"PC", "Server"})
138
+
139
+ for tag, words in CAPABILITY_KEYWORDS.items():
140
+ if any(word in rel or word in rel_flat for word in words):
141
+ tags.add(tag)
142
+ if router_count >= 2:
143
+ tags.add("multi_router")
144
+ if switch_count >= 1:
145
+ tags.add("switching")
146
+ if host_count >= 1:
147
+ tags.add("host_server")
148
+ if any(dtype in {"WirelessRouter", "LightWeightAccessPoint"} for dtype in normalized_types):
149
+ tags.add("wireless")
150
+ tags.add("wireless_ap")
151
+ if any(dtype in {"Tablet", "Laptop", "Smartphone"} for dtype in normalized_types):
152
+ tags.add("wireless_client")
153
+ if any(dtype == "Tablet" for dtype in normalized_types):
154
+ tags.add("tablet")
155
+ if any(dtype == "Printer" for dtype in normalized_types):
156
+ tags.add("printer")
157
+ if "telnet" in rel or "terminal server" in rel_flat:
158
+ tags.add("telnet")
159
+ if "management" in rel_flat:
160
+ tags.add("management_vlan")
161
+ return sorted(tags or {"switching"})
162
+
163
+
164
+ def infer_topology_tags(item: dict[str, Any]) -> list[str]:
165
+ tags: set[str] = set()
166
+ rel = item.get("relative_path", "").lower().replace("\\", "/")
167
+ counts = _normalized_counts_for_item(item)
168
+ link_count = int(item.get("link_count", 0))
169
+ switch_count = counts.get("Switch", 0)
170
+ router_count = counts.get("Router", 0)
171
+ wireless_count = counts.get("LightWeightAccessPoint", 0) + counts.get("WirelessRouter", 0)
172
+ server_count = counts.get("Server", 0)
173
+
174
+ if "router_on_a_stick" in item.get("capability_tags", []):
175
+ tags.add("router_on_a_stick")
176
+ if "acl" in item.get("capability_tags", []):
177
+ tags.add("acl_policy")
178
+ if wireless_count:
179
+ tags.add("wireless_edge")
180
+ if server_count:
181
+ tags.add("server_services")
182
+ if switch_count >= 3 and router_count >= 1:
183
+ tags.add("department_lan")
184
+ if switch_count >= 2 and router_count >= 1:
185
+ tags.add("core_access")
186
+ if switch_count >= 2 and link_count >= switch_count:
187
+ tags.add("chain")
188
+ if router_count == 1 and switch_count <= 1 and wireless_count:
189
+ tags.add("small_office")
190
+ if "campus" in rel or "department" in rel or "vlan" in rel:
191
+ tags.add("department_lan")
192
+ return sorted(tags or {"general"})
193
+
194
+
195
+ def infer_preferred_roles(item: dict[str, Any]) -> list[str]:
196
+ roles: list[str] = []
197
+ tags = set(item.get("capability_tags", []))
198
+ if "vlan" in tags:
199
+ roles.append("preferred_vlan")
200
+ if "dhcp_pool" in tags:
201
+ roles.append("preferred_dhcp")
202
+ if {"ospf", "eigrp", "rip"} & tags:
203
+ roles.append("preferred_routing")
204
+ if {"nat", "acl", "vpn"} & tags:
205
+ roles.append("preferred_security")
206
+ if "wireless" in tags:
207
+ roles.append("preferred_wireless")
208
+ if "telnet" in tags or "management_vlan" in tags:
209
+ roles.append("preferred_management")
210
+ if "server_dns" in tags or "server_dhcp" in tags:
211
+ roles.append("preferred_server")
212
+ if "iot" in tags:
213
+ roles.append("preferred_iot")
214
+ if not roles:
215
+ roles.append("preferred_general")
216
+ return roles
217
+
218
+
219
+ def enrich_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
220
+ enriched: list[dict[str, Any]] = []
221
+ for item in items:
222
+ if "error" in item:
223
+ enriched.append(item)
224
+ continue
225
+ new_item = dict(item)
226
+ new_item["capability_tags"] = infer_capability_tags(new_item)
227
+ new_item["topology_tags"] = infer_topology_tags(new_item)
228
+ new_item["preferred_roles"] = infer_preferred_roles(new_item)
229
+ new_item.setdefault("trust_level", "trusted")
230
+ new_item.setdefault("origin", "cisco-local")
231
+ new_item.setdefault("role", "primary")
232
+ new_item.setdefault("prototype_eligible", True)
233
+ enriched.append(new_item)
234
+ return enriched
235
+
236
+
237
+ @lru_cache(maxsize=8)
238
+ def _load_catalog_cached(path_str: str) -> tuple[SampleDescriptor, ...]:
239
+ raw_items = json.loads(Path(path_str).read_text(encoding="utf-8"))
240
+ items = enrich_catalog_items(raw_items)
241
+ saves_root = get_packet_tracer_saves_root()
242
+ return tuple(
243
+ SampleDescriptor(
244
+ path=str((saves_root / item["relative_path"]) if saves_root is not None else item.get("path", item["relative_path"])),
245
+ relative_path=item["relative_path"],
246
+ version=item.get("version", ""),
247
+ device_count=item.get("device_count", 0),
248
+ link_count=item.get("link_count", 0),
249
+ devices=item.get("devices", []),
250
+ links=item.get("links", []),
251
+ capability_tags=item.get("capability_tags", []),
252
+ topology_tags=item.get("topology_tags", []),
253
+ preferred_roles=item.get("preferred_roles", []),
254
+ trust_level=item.get("trust_level", "trusted"),
255
+ origin=item.get("origin", "cisco-local"),
256
+ role=item.get("role", "primary"),
257
+ prototype_eligible=bool(item.get("prototype_eligible", True)),
258
+ )
259
+ for item in items
260
+ if "error" not in item
261
+ )
262
+
263
+
264
+ def load_catalog(path: Path | None = None) -> list[SampleDescriptor]:
265
+ return list(_load_catalog_cached(str(path or DEFAULT_CATALOG_JSON)))
266
+
267
+
268
+ def _summarize_pkt(path: Path, relative_path: str, origin: str, prototype_eligible: bool) -> dict[str, Any]:
269
+ xml = decode_pkt_modern(path.read_bytes())
270
+ root = ET.fromstring(xml)
271
+ devices = []
272
+ for device in root.findall(".//DEVICES/DEVICE"):
273
+ type_node = device.find("./ENGINE/TYPE")
274
+ devices.append(
275
+ {
276
+ "name": device.findtext("./ENGINE/NAME", default=""),
277
+ "type": device.findtext("./ENGINE/TYPE", default=""),
278
+ "model": type_node.get("model", "") if type_node is not None else "",
279
+ }
280
+ )
281
+ links = []
282
+ for link in root.findall(".//LINKS/LINK"):
283
+ cable = link.find("./CABLE")
284
+ ports = cable.findall("PORT") if cable is not None else []
285
+ links.append(
286
+ {
287
+ "type": link.findtext("./TYPE", default=""),
288
+ "cable_type": cable.findtext("./TYPE", default="") if cable is not None else "",
289
+ "ports": [port.text or "" for port in ports[:2]],
290
+ }
291
+ )
292
+ return {
293
+ "path": str(path),
294
+ "relative_path": relative_path,
295
+ "version": root.findtext("./VERSION", default=""),
296
+ "device_count": len(devices),
297
+ "link_count": len(links),
298
+ "devices": devices,
299
+ "links": links,
300
+ "origin": origin,
301
+ "trust_level": "reference-only" if origin != "cisco-local" else "trusted",
302
+ "role": "reference" if origin != "cisco-local" else "primary",
303
+ "prototype_eligible": prototype_eligible,
304
+ }
305
+
306
+
307
+ @lru_cache(maxsize=4)
308
+ def _load_reference_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
309
+ items: list[dict[str, Any]] = []
310
+ for root_str in roots_key:
311
+ root = Path(root_str)
312
+ if not root.exists():
313
+ continue
314
+ for pkt_path in sorted(root.rglob("*.pkt")):
315
+ try:
316
+ rel = str(pkt_path.relative_to(root))
317
+ items.append(_summarize_pkt(pkt_path, rel, "external-reference", False))
318
+ except Exception:
319
+ continue
320
+ enriched = enrich_catalog_items(items)
321
+ return tuple(
322
+ SampleDescriptor(
323
+ path=item["path"],
324
+ relative_path=item["relative_path"],
325
+ version=item.get("version", ""),
326
+ device_count=item.get("device_count", 0),
327
+ link_count=item.get("link_count", 0),
328
+ devices=item.get("devices", []),
329
+ links=item.get("links", []),
330
+ capability_tags=item.get("capability_tags", []),
331
+ topology_tags=item.get("topology_tags", []),
332
+ preferred_roles=item.get("preferred_roles", []),
333
+ trust_level=item.get("trust_level", "reference-only"),
334
+ origin=item.get("origin", "external-reference"),
335
+ role=item.get("role", "reference"),
336
+ prototype_eligible=bool(item.get("prototype_eligible", False)),
337
+ )
338
+ for item in enriched
339
+ )
340
+
341
+
342
+ def load_reference_catalog(reference_roots: list[Path] | None = None) -> list[SampleDescriptor]:
343
+ if not reference_roots:
344
+ return []
345
+ roots_key = tuple(sorted(str(path) for path in reference_roots))
346
+ return list(_load_reference_catalog_cached(roots_key))
347
+
348
+
349
+ def extract_reference_patterns(samples: list[SampleDescriptor]) -> list[ReferencePattern]:
350
+ patterns: list[ReferencePattern] = []
351
+ for sample in samples:
352
+ patterns.append(
353
+ ReferencePattern(
354
+ relative_path=sample.relative_path,
355
+ origin=sample.origin,
356
+ capability_tags=sample.capability_tags,
357
+ topology_tags=sample.topology_tags,
358
+ device_summary=sample.normalized_device_counts(),
359
+ )
360
+ )
361
+ return patterns
362
+
363
+
364
+ def write_catalog_outputs(items: list[dict[str, Any]], json_path: Path | None = None, md_path: Path | None = None) -> None:
365
+ enriched = enrich_catalog_items(items)
366
+ compact_items: list[dict[str, Any]] = []
367
+ for item in enriched:
368
+ saved = dict(item)
369
+ saved.pop("path", None)
370
+ compact_items.append(saved)
371
+ (json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(compact_items, indent=2, ensure_ascii=False), encoding="utf-8")
372
+ root_label = "<PACKET_TRACER_SAVES_ROOT>"
373
+ lines = ["# Packet Tracer Installed Sample Catalog", "", f"Source root: `{root_label}`", ""]
374
+ for item in enriched:
375
+ if "error" in item:
376
+ lines.append(f"- `{item['relative_path']}`")
377
+ lines.append(f" decode error: `{item['error']}`")
378
+ continue
379
+ labels = ", ".join(f"{device['name']} ({device['type']}/{device['model']})" for device in item["devices"])
380
+ lines.append(f"- `{item['relative_path']}`")
381
+ lines.append(f" version: `{item['version']}`, devices: `{item['device_count']}`, links: `{item['link_count']}`")
382
+ lines.append(f" tags: {', '.join(item.get('capability_tags', []))}")
383
+ lines.append(f" topology: {', '.join(item.get('topology_tags', []))}")
384
+ lines.append(f" devices: {labels}")
385
+ (md_path or DEFAULT_CATALOG_MD).write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ from sample_catalog import SampleCandidate, SampleDescriptor
4
+
5
+
6
+ def _device_fit_score(sample: SampleDescriptor, device_requirements: dict[str, int]) -> tuple[int, list[str]]:
7
+ score = 0
8
+ reasons: list[str] = []
9
+ counts = sample.normalized_device_counts()
10
+ for device_type, needed in device_requirements.items():
11
+ available = counts.get(device_type, 0)
12
+ if available >= needed:
13
+ score += 5 + needed
14
+ reasons.append(f"device:{device_type}")
15
+ elif available > 0:
16
+ score += available
17
+ reasons.append(f"partial-device:{device_type}")
18
+ return score, reasons
19
+
20
+
21
+ def _score_sample(
22
+ sample: SampleDescriptor,
23
+ capabilities: list[str],
24
+ device_requirements: dict[str, int],
25
+ topology_tags: list[str] | None = None,
26
+ ) -> SampleCandidate:
27
+ score = 0
28
+ topology_score = 0
29
+ reasons: list[str] = []
30
+ tags = set(sample.capability_tags)
31
+ for capability in capabilities:
32
+ if capability in tags:
33
+ score += 10
34
+ reasons.append(f"capability:{capability}")
35
+ if any(role in sample.preferred_roles for role in ["preferred_wireless", "preferred_management", "preferred_server"]):
36
+ score += 4
37
+ reasons.append("preferred-role")
38
+ device_score, device_reasons = _device_fit_score(sample, device_requirements)
39
+ score += device_score
40
+ reasons.extend(device_reasons)
41
+ counts = sample.normalized_device_counts()
42
+ if sample.version.startswith("9."):
43
+ score += 2
44
+ reasons.append("version:9.x")
45
+ score += min(sample.link_count, 10)
46
+ if sample.origin == "cisco-local":
47
+ score += 20
48
+ reasons.append("origin:cisco-local")
49
+ if sample.prototype_eligible:
50
+ score += 10
51
+ reasons.append("prototype-eligible")
52
+ else:
53
+ score -= 50
54
+ wanted_topology = set(topology_tags or [])
55
+ available_topology = set(sample.topology_tags)
56
+ for tag in wanted_topology:
57
+ if tag in available_topology:
58
+ topology_score += 12
59
+ reasons.append(f"topology:{tag}")
60
+ total_score = score + topology_score
61
+ return SampleCandidate(
62
+ sample=sample,
63
+ capability_score=score,
64
+ topology_score=topology_score,
65
+ total_score=total_score,
66
+ reasons=reasons,
67
+ )
68
+
69
+
70
+ def _score_reference_sample(
71
+ sample: SampleDescriptor,
72
+ capabilities: list[str],
73
+ device_requirements: dict[str, int],
74
+ topology_tags: list[str] | None = None,
75
+ ) -> SampleCandidate:
76
+ score = 0
77
+ topology_score = 0
78
+ reasons: list[str] = []
79
+ tags = set(sample.capability_tags)
80
+ for capability in capabilities:
81
+ if capability in tags:
82
+ score += 8
83
+ reasons.append(f"capability:{capability}")
84
+ device_score, device_reasons = _device_fit_score(sample, device_requirements)
85
+ score += device_score
86
+ reasons.extend(device_reasons)
87
+ wanted_topology = set(topology_tags or [])
88
+ available_topology = set(sample.topology_tags)
89
+ for tag in wanted_topology:
90
+ if tag in available_topology:
91
+ topology_score += 14
92
+ reasons.append(f"topology:{tag}")
93
+ if sample.origin == "external-reference":
94
+ score += 6
95
+ reasons.append("origin:external-reference")
96
+ if not sample.prototype_eligible:
97
+ reasons.append("reference-only")
98
+ total_score = score + topology_score
99
+ return SampleCandidate(
100
+ sample=sample,
101
+ capability_score=score,
102
+ topology_score=topology_score,
103
+ total_score=total_score,
104
+ reasons=reasons,
105
+ )
106
+
107
+
108
+ def rank_samples(
109
+ samples: list[SampleDescriptor],
110
+ capabilities: list[str],
111
+ device_requirements: dict[str, int],
112
+ topology_tags: list[str] | None = None,
113
+ prototype_only: bool = True,
114
+ ) -> list[SampleCandidate]:
115
+ filtered: list[SampleDescriptor] = []
116
+ for sample in samples:
117
+ if prototype_only and not sample.prototype_eligible:
118
+ continue
119
+ counts = sample.normalized_device_counts()
120
+ if all(counts.get(device_type, 0) >= needed for device_type, needed in device_requirements.items()):
121
+ filtered.append(sample)
122
+ candidates = filtered or samples
123
+ ranked = sorted(
124
+ (_score_sample(sample, capabilities, device_requirements, topology_tags) for sample in candidates if (sample.prototype_eligible or not prototype_only)),
125
+ key=lambda candidate: (candidate.total_score, candidate.sample.device_count, candidate.sample.link_count),
126
+ reverse=True,
127
+ )
128
+ return ranked
129
+
130
+
131
+ def rank_reference_samples(
132
+ samples: list[SampleDescriptor],
133
+ capabilities: list[str],
134
+ device_requirements: dict[str, int],
135
+ topology_tags: list[str] | None = None,
136
+ ) -> list[SampleCandidate]:
137
+ external_only = [sample for sample in samples if sample.origin == "external-reference"]
138
+ candidates = external_only or samples
139
+ ranked = sorted(
140
+ (_score_reference_sample(sample, capabilities, device_requirements, topology_tags) for sample in candidates),
141
+ key=lambda candidate: (candidate.total_score, candidate.sample.device_count, candidate.sample.link_count),
142
+ reverse=True,
143
+ )
144
+ return ranked
145
+
146
+
147
+ def select_best_sample(
148
+ samples: list[SampleDescriptor],
149
+ capabilities: list[str],
150
+ device_requirements: dict[str, int],
151
+ topology_tags: list[str] | None = None,
152
+ ) -> SampleDescriptor:
153
+ ranked = rank_samples(samples, capabilities, device_requirements, topology_tags=topology_tags, prototype_only=True)
154
+ if not ranked:
155
+ raise ValueError("No Packet Tracer sample is available for selection")
156
+ return ranked[0].sample
@@ -0,0 +1,26 @@
1
+ param(
2
+ [string]$VenvPath = ".venv",
3
+ [switch]$Dev
4
+ )
5
+
6
+ $ErrorActionPreference = "Stop"
7
+
8
+ if (-not (Test-Path $VenvPath)) {
9
+ python -m venv $VenvPath
10
+ }
11
+
12
+ $python = Join-Path $VenvPath "Scripts\\python.exe"
13
+ if (-not (Test-Path $python)) {
14
+ throw "Virtual environment python not found at $python"
15
+ }
16
+
17
+ & $python -m pip install --upgrade pip
18
+ & $python -m pip install -r "requirements.txt"
19
+
20
+ if ($Dev) {
21
+ & $python -m pip install -r "requirements-dev.txt"
22
+ }
23
+
24
+ Write-Output "Setup complete."
25
+ Write-Output "Venv python: $python"
26
+ Write-Output "If you use Packet Tracer 9.x encoding/decoding, also set PKT_TWOFISH_LIBRARY."
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python3
2
+ """Runtime diagnostics for the local Twofish bridge."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import hashlib
8
+ import os
9
+ import sys
10
+ from ctypes import CDLL
11
+ from pathlib import Path
12
+
13
+
14
+ SUPPORTED_PYTHON = (3, 14)
15
+
16
+
17
+ def _vendor_dir() -> Path:
18
+ return Path(__file__).resolve().parent / "vendor"
19
+
20
+
21
+ def _candidate_paths() -> list[tuple[str, Path]]:
22
+ vendor_dir = _vendor_dir()
23
+ env_path = os.getenv("PKT_TWOFISH_LIBRARY")
24
+ candidates: list[tuple[str, Path]] = []
25
+ if env_path:
26
+ candidates.append(("env", Path(env_path).expanduser()))
27
+ for pattern in ("_twofish*.pyd", "_twofish*.so", "_twofish*.dll"):
28
+ for candidate in sorted(vendor_dir.glob(pattern)):
29
+ candidates.append(("sibling", candidate))
30
+ return candidates
31
+
32
+
33
+ def main() -> int:
34
+ python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
35
+ python_supported = sys.version_info[:2] == SUPPORTED_PYTHON
36
+ result = {
37
+ "python_version": python_version,
38
+ "python_support_status": "ok" if python_supported else "unsupported",
39
+ "python_support_message": (
40
+ "supported"
41
+ if python_supported
42
+ else f"requires Python {SUPPORTED_PYTHON[0]}.{SUPPORTED_PYTHON[1]}.x"
43
+ ),
44
+ "resolved_twofish_path": "",
45
+ "twofish_source": "",
46
+ "twofish_load_status": "missing",
47
+ "twofish_message": "no local Twofish bridge was found",
48
+ "twofish_sha256": "",
49
+ }
50
+
51
+ for source, candidate in _candidate_paths():
52
+ if not candidate.exists():
53
+ continue
54
+ result["resolved_twofish_path"] = str(candidate)
55
+ result["twofish_source"] = source
56
+ result["twofish_sha256"] = hashlib.sha256(candidate.read_bytes()).hexdigest()
57
+ if not python_supported:
58
+ result["twofish_load_status"] = "python_unsupported"
59
+ result["twofish_message"] = (
60
+ f"found {candidate}, but this bridge is only supported with Python "
61
+ f"{SUPPORTED_PYTHON[0]}.{SUPPORTED_PYTHON[1]}.x"
62
+ )
63
+ print(json.dumps(result))
64
+ return 0
65
+ try:
66
+ library = CDLL(str(candidate))
67
+ getattr(library, "exp_Twofish_encrypt")
68
+ getattr(library, "exp_Twofish_decrypt")
69
+ result["twofish_load_status"] = "ok"
70
+ result["twofish_message"] = f"loaded {candidate}"
71
+ print(json.dumps(result))
72
+ return 0
73
+ except Exception as exc: # pragma: no cover - runtime diagnostics
74
+ result["twofish_load_status"] = "load_error"
75
+ result["twofish_message"] = f"{candidate}: {exc}"
76
+ print(json.dumps(result))
77
+ return 0
78
+
79
+ env_path = os.getenv("PKT_TWOFISH_LIBRARY")
80
+ if env_path:
81
+ result["resolved_twofish_path"] = str(Path(env_path).expanduser())
82
+ result["twofish_source"] = "env"
83
+ result["twofish_load_status"] = "missing"
84
+ result["twofish_message"] = f"set but missing: {env_path}"
85
+
86
+ print(json.dumps(result))
87
+ return 0
88
+
89
+
90
+ if __name__ == "__main__":
91
+ raise SystemExit(main())