packet-tracer-skill 0.2.0 → 0.2.2

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 (57) hide show
  1. package/CHANGELOG.md +42 -4
  2. package/README.md +237 -88
  3. package/docs/campus-donor-proof.md +103 -0
  4. package/docs/curated-donor-registry.md +26 -0
  5. package/docs/github-launch-ops-0.2.1.md +45 -0
  6. package/docs/github-launch-ops-0.2.2.md +42 -0
  7. package/docs/github-metadata.md +23 -6
  8. package/docs/hero-demo-plan.md +18 -10
  9. package/docs/home-iot-donor-proof.md +64 -0
  10. package/docs/launch-announcement-0.2.1.md +15 -0
  11. package/docs/launch-announcement-0.2.2.md +15 -0
  12. package/docs/packet-tracer-feature-gap-atlas.md +87 -0
  13. package/docs/post-launch-follow-up.md +39 -0
  14. package/docs/publish-preview-roadmap.md +20 -17
  15. package/docs/release-checklist.md +22 -12
  16. package/docs/{release-notes-0.2.0.md → release-notes-0.2.1.md} +7 -6
  17. package/docs/release-notes-0.2.2.md +26 -0
  18. package/docs/runtime-truth.md +31 -0
  19. package/docs/wan-security-donor-proof.md +41 -0
  20. package/docs/wireless-advanced-proof.md +60 -0
  21. package/examples/README.md +13 -10
  22. package/examples/gallery.md +10 -4
  23. package/examples/index.json +3 -3
  24. package/package.json +44 -16
  25. package/references/curated-donor-registry.json +5 -2
  26. package/references/packettracer-feature-atlas.json +133 -0
  27. package/references/scenario-fixture-corpus.json +1 -0
  28. package/scripts/build_examples_index.py +27 -16
  29. package/scripts/coverage_matrix.py +559 -23
  30. package/scripts/feature_atlas.py +229 -0
  31. package/scripts/generate_pkt.py +330 -31
  32. package/scripts/intent_parser.py +278 -7
  33. package/scripts/pkt_editor.py +174 -0
  34. package/scripts/runtime_doctor.py +83 -10
  35. package/scripts/sample_catalog.py +210 -5
  36. package/docs/screenshots/.gitkeep +0 -1
  37. package/docs/screenshots/packet-tracer-topology-cropped.png +0 -0
  38. package/scripts/__pycache__/build_examples_index.cpython-314.pyc +0 -0
  39. package/scripts/__pycache__/build_sample_catalog.cpython-314.pyc +0 -0
  40. package/scripts/__pycache__/coverage_matrix.cpython-314.pyc +0 -0
  41. package/scripts/__pycache__/donor_diagnostics.cpython-314.pyc +0 -0
  42. package/scripts/__pycache__/generate_pkt.cpython-314.pyc +0 -0
  43. package/scripts/__pycache__/install_skill.cpython-314.pyc +0 -0
  44. package/scripts/__pycache__/intent_parser.cpython-314.pyc +0 -0
  45. package/scripts/__pycache__/packet_tracer_env.cpython-314.pyc +0 -0
  46. package/scripts/__pycache__/pkt_builder.cpython-314.pyc +0 -0
  47. package/scripts/__pycache__/pkt_codec.cpython-314.pyc +0 -0
  48. package/scripts/__pycache__/pkt_editor.cpython-314.pyc +0 -0
  49. package/scripts/__pycache__/pkt_transformer.cpython-314.pyc +0 -0
  50. package/scripts/__pycache__/remote_search.cpython-314.pyc +0 -0
  51. package/scripts/__pycache__/runtime_doctor.cpython-314.pyc +0 -0
  52. package/scripts/__pycache__/sample_catalog.cpython-314.pyc +0 -0
  53. package/scripts/__pycache__/sample_selector.cpython-314.pyc +0 -0
  54. package/scripts/__pycache__/twofish_diagnostics.cpython-314.pyc +0 -0
  55. package/scripts/__pycache__/twofish_runtime.cpython-314.pyc +0 -0
  56. package/scripts/__pycache__/workspace_repair.cpython-314.pyc +0 -0
  57. package/scripts/vendor/__pycache__/twofish.cpython-314.pyc +0 -0
@@ -43,23 +43,62 @@ def runtime_env_examples(host_os: str) -> list[str]:
43
43
  return [
44
44
  r"$env:PACKET_TRACER_ROOT='C:\Program Files\Cisco Packet Tracer 9.0.0'",
45
45
  r"$env:PACKET_TRACER_COMPAT_DONOR='C:\path\to\your-working-9.0-donor.pkt'",
46
- r'$env:PKT_TWOFISH_LIBRARY="$env:USERPROFILE\.codex\skills\pkt\scripts\vendor\_twofish.cp314-win_amd64.pyd"',
46
+ r'$env:PKT_TWOFISH_LIBRARY="C:\path\to\_twofish.cp314-win_amd64.pyd"',
47
+ r'$env:PKT_TWOFISH_SEARCH_ROOTS="C:\path\to\bridge-folder"',
47
48
  ]
48
49
  if host_os == "macOS":
49
50
  return [
50
51
  "export PACKET_TRACER_ROOT='/Applications/Cisco Packet Tracer.app/Contents/Resources'",
51
52
  "export PACKET_TRACER_COMPAT_DONOR=\"$HOME/path/to/your-working-9.0-donor.pkt\"",
52
- "export PKT_TWOFISH_SEARCH_ROOTS=\"$HOME/.codex/skills/pkt/scripts/vendor:$HOME/pkt-bridges\"",
53
+ "export PKT_TWOFISH_SEARCH_ROOTS=\"$HOME/path/to/bridge-folder:$HOME/pkt-bridges\"",
53
54
  ]
54
55
  if host_os == "Linux":
55
56
  return [
56
57
  "export PACKET_TRACER_ROOT='/opt/pt/bin'",
57
58
  "export PACKET_TRACER_COMPAT_DONOR=\"$HOME/path/to/your-working-9.0-donor.pkt\"",
58
- "export PKT_TWOFISH_SEARCH_ROOTS=\"$HOME/.codex/skills/pkt/scripts/vendor:$HOME/pkt-bridges\"",
59
+ "export PKT_TWOFISH_SEARCH_ROOTS=\"$HOME/path/to/bridge-folder:$HOME/pkt-bridges\"",
59
60
  ]
60
61
  return []
61
62
 
62
63
 
64
+ def _format_operation_list(operations: list[str]) -> str:
65
+ return ", ".join(operations) if operations else "none"
66
+
67
+
68
+ def _best_next_fix(runtime_blockers: list[str], recommended_next_steps: list[str]) -> str:
69
+ blocker_map = {
70
+ "missing_or_incompatible_donor": "Fix the donor path first so inventory/edit/generate can use a compatible 9.0 donor.",
71
+ "missing_twofish_bridge": "Fix the Twofish bridge next so strict decode/edit/generate can run locally.",
72
+ "missing_packet_tracer_root": "Fix PACKET_TRACER_ROOT so the doctor can resolve the install layout deterministically.",
73
+ "missing_packet_tracer_executable": "Fix the Packet Tracer install root or executable path before relying on validate_open.",
74
+ "windows_first_runtime": "Do not assume non-Windows strict runtime support without a custom native bridge and explicit Packet Tracer paths.",
75
+ "using_external_bridge_only": "Move the external bridge into the repo-local vendor path if you need a self-contained runtime claim.",
76
+ }
77
+ for blocker in runtime_blockers:
78
+ if blocker in blocker_map:
79
+ return blocker_map[blocker]
80
+ return recommended_next_steps[0] if recommended_next_steps else "No immediate fix is required."
81
+
82
+
83
+ def _why_it_is_blocked(runtime_blockers: list[str], bridge_resolution: str) -> str:
84
+ if not runtime_blockers:
85
+ return "No runtime blocker is currently active."
86
+ reasons: list[str] = []
87
+ if "missing_or_incompatible_donor" in runtime_blockers:
88
+ reasons.append("no compatible donor is resolved")
89
+ if "missing_twofish_bridge" in runtime_blockers:
90
+ reasons.append("no local Twofish bridge is resolved")
91
+ if "missing_packet_tracer_root" in runtime_blockers:
92
+ reasons.append("Packet Tracer root is not resolved")
93
+ if "missing_packet_tracer_executable" in runtime_blockers:
94
+ reasons.append("Packet Tracer executable is not resolved")
95
+ if "windows_first_runtime" in runtime_blockers:
96
+ reasons.append("strict bundled validation is still Windows-first")
97
+ if bridge_resolution == "external_env":
98
+ reasons.append("strict runtime currently relies on an external bridge override")
99
+ return "; ".join(reasons) + "."
100
+
101
+
63
102
  def build_recommended_next_steps(
64
103
  *,
65
104
  host_os: str,
@@ -205,13 +244,43 @@ def collect_runtime_doctor() -> dict[str, object]:
205
244
  else:
206
245
  runtime_grade = "ready"
207
246
  if runtime_grade == "ready":
208
- doctor_summary = "Runtime looks ready for decode, edit, generate, and validate-open."
247
+ doctor_summary = (
248
+ "Runtime is ready. "
249
+ f"Ready operations: {_format_operation_list(ready_operations)}."
250
+ )
251
+ elif bridge_resolution == "external_env" and not blocked_operations:
252
+ doctor_summary = (
253
+ "Runtime operations are ready, but packaged runtime remains partially ready because the bridge "
254
+ "comes from an external environment instead of the repo-local vendor path. "
255
+ f"Ready operations: {_format_operation_list(ready_operations)}."
256
+ )
209
257
  elif runtime_grade == "partially_ready":
210
- doctor_summary = "Runtime is partially ready: some operations work, but strict .pkt generation is still blocked."
258
+ doctor_summary = (
259
+ "Runtime is partially ready. "
260
+ f"Ready operations: {_format_operation_list(ready_operations)}. "
261
+ f"Blocked operations: {_format_operation_list(blocked_operations)}."
262
+ )
211
263
  else:
212
- doctor_summary = "Runtime is blocked: required donor, bridge, or Packet Tracer runtime pieces are missing."
213
- if bridge_resolution == "external_env":
264
+ doctor_summary = (
265
+ "Runtime is blocked. "
266
+ f"Blocked operations: {_format_operation_list(blocked_operations)}."
267
+ )
268
+ if bridge_resolution == "external_env" and blocked_operations:
214
269
  doctor_summary = f"{doctor_summary} External bridge is being used instead of a repo-local vendor bridge."
270
+ elif bridge_resolution == "missing" and runtime_grade != "ready":
271
+ doctor_summary = f"{doctor_summary} Strict decode/edit/generate remain blocked until a local bridge is resolved."
272
+ what_currently_works = (
273
+ f"Currently working operations: {_format_operation_list(ready_operations)}."
274
+ if ready_operations
275
+ else "No strict runtime operations are currently working."
276
+ )
277
+ what_is_blocked = (
278
+ f"Blocked operations: {_format_operation_list(blocked_operations)}."
279
+ if blocked_operations
280
+ else "No runtime operations are currently blocked."
281
+ )
282
+ why_it_is_blocked = _why_it_is_blocked(runtime_blockers, bridge_resolution)
283
+ best_next_fix = _best_next_fix(runtime_blockers, recommended_next_steps)
215
284
  bridge_recommendation = (
216
285
  "Use or install a repo-local vendor bridge for fully self-contained runtime readiness."
217
286
  if bridge_resolution == "external_env"
@@ -220,11 +289,11 @@ def collect_runtime_doctor() -> dict[str, object]:
220
289
  else "Repo-local bridge is resolved."
221
290
  )
222
291
  runtime_contract_notes = (
223
- "Repo-local bridge and donor are present."
292
+ "Repo-local bridge and donor are present, so this checkout can run strict decode/edit/generate locally."
224
293
  if bridge_resolution == "repo_local"
225
- else "External bridge resolves decode/edit, but repo-local runtime packaging is still incomplete."
294
+ else "Strict decode/edit/generate currently rely on an external bridge path. Repo-local runtime packaging is still incomplete."
226
295
  if bridge_resolution == "external_env"
227
- else "Bridge resolution is missing, so strict runtime remains blocked."
296
+ else "No bridge is resolved. validate_open may still work when Packet Tracer is installed, but strict decode/edit/generate remain blocked."
228
297
  )
229
298
 
230
299
  return {
@@ -270,6 +339,10 @@ def collect_runtime_doctor() -> dict[str, object]:
270
339
  "runtime_blockers": runtime_blockers,
271
340
  "ready_operations": ready_operations,
272
341
  "blocked_operations": blocked_operations,
342
+ "what_currently_works": what_currently_works,
343
+ "what_is_blocked": what_is_blocked,
344
+ "why_it_is_blocked": why_it_is_blocked,
345
+ "best_next_fix": best_next_fix,
273
346
  "doctor_summary": doctor_summary,
274
347
  "runtime_grade": runtime_grade,
275
348
  "recommended_next_steps": recommended_next_steps,
@@ -24,20 +24,82 @@ CAPABILITY_KEYWORDS = {
24
24
  "dhcp_pool": ["dhcp", "reservation", "apipa"],
25
25
  "router_dhcp": ["dhcp", "reservation", "apipa"],
26
26
  "server_dhcp": ["dhcp", "reservation", "apipa"],
27
+ "dhcpv6_stateful": ["dhcpv6", "stateful_dhcpv6"],
28
+ "dhcpv6_stateless": ["stateless_dhcpv6"],
29
+ "ipv6_slaac": ["slaac"],
30
+ "ipv6_prefix_delegation": ["prefix_delegation"],
31
+ "ipv6_dns_aaaa": ["aaaa"],
32
+ "ipv6_tunneling": ["ipv6ip", "ipv6 tunneling"],
33
+ "isatap": ["isatap"],
27
34
  "dhcp_snooping": ["dhcp snooping", "option_82", "trusted_untrusted"],
28
35
  "ospf": ["ospf"],
36
+ "ospfv3": ["ospfv3", "ipv6_ospf"],
29
37
  "eigrp": ["eigrp"],
38
+ "eigrp_ipv6": ["ipv6_eigrp"],
30
39
  "rip": ["rip"],
40
+ "ripng": ["ripng", "ipv6 rip"],
41
+ "hsrp": ["hsrp"],
31
42
  "nat": ["nat"],
32
43
  "acl": ["acl", "access-list"],
44
+ "dai": ["dai", "dynamic arp inspection"],
45
+ "dot1x": ["dot1x", "802.1x", "port-based nac"],
46
+ "lldp": ["lldp"],
47
+ "rep": ["rep_"],
48
+ "snmp": ["snmp"],
49
+ "netflow": ["netflow"],
50
+ "span": ["span", "rspan"],
51
+ "qos": ["qos"],
52
+ "port_security": ["port security", "port-security"],
33
53
  "vpn": ["vpn", "ipsec", "gre"],
34
54
  "ipsec": ["ipsec", "ike"],
35
55
  "gre": ["gre"],
36
56
  "ppp": ["ppp", "chap", "pap"],
57
+ "wan": ["wan", "cloud", "serial", "pppoe", "ppp"],
58
+ "security_edge": ["asa", "firewall", "security appliance"],
59
+ "asa_acl_nat": ["asa_acl_nat"],
60
+ "asa_service_policy": ["asa_service_policy", "service policy"],
61
+ "clientless_vpn": ["clientless_vpn"],
62
+ "cbac": ["cbac"],
63
+ "zfw": ["zfw", "zone based firewall"],
64
+ "sniffer": ["sniffer"],
37
65
  "multilayer_switching": ["multilayer", "layer 3", "3560", "svi"],
38
66
  "wireless": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep", "5g", "bluetooth", "cellular"],
39
67
  "wireless_ap": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep"],
40
68
  "wireless_client": ["wireless", "wifi", "tablet", "smartphone", "laptop"],
69
+ "wlc": ["wlc", "wireless lan controller"],
70
+ "wpa_enterprise": ["wpa_radius", "wpa2_enterprise"],
71
+ "wep": ["wep"],
72
+ "guest_wifi": ["guest"],
73
+ "beamforming": ["beamforming"],
74
+ "meraki": ["meraki"],
75
+ "cellular_5g": ["5g", "cellular", "cell tower"],
76
+ "bluetooth": ["bluetooth"],
77
+ "network_controller": ["network controller", "netcon"],
78
+ "python_programming": ["python"],
79
+ "javascript_programming": ["javascript"],
80
+ "blockly_programming": ["blockly"],
81
+ "tcp_udp_app": ["tcp_test_app", "udp_test_app"],
82
+ "vm_iox": ["iox", "uploading_and_running_vm"],
83
+ "voip": ["voip"],
84
+ "ip_phone": ["ip phone", "phone"],
85
+ "call_manager": ["call manager", "telephony", "voip"],
86
+ "linksys_voice": ["voip linksys"],
87
+ "mqtt": ["mqtt"],
88
+ "real_http": ["real-http", "real http"],
89
+ "real_websocket": ["real-websocket", "real websocket"],
90
+ "visual_scripting": ["visual-scripting", "visual scripting"],
91
+ "ptp": ["ptp"],
92
+ "profinet": ["profinet"],
93
+ "l2nat": ["l2nat"],
94
+ "cyberobserver": ["cyberobserver"],
95
+ "industrial_firewall": ["isa3000", "industrial cybersecurity"],
96
+ "coaxial": ["coaxial"],
97
+ "cable_dsl": ["cable modem", "dsl modem"],
98
+ "central_office": ["centraloffice", "central office"],
99
+ "cell_tower": ["cell tower"],
100
+ "power_distribution": ["power distribution"],
101
+ "hot_swappable": ["hotswappable", "hot swappable"],
102
+ "ios_license": ["ios_15", "license"],
41
103
  "ntp": ["ntp"],
42
104
  "dns": ["dns"],
43
105
  "server_dns": ["dns"],
@@ -54,6 +116,70 @@ CAPABILITY_KEYWORDS = {
54
116
  "printer": ["printer"],
55
117
  }
56
118
 
119
+ REPORT_ONLY_CAPABILITIES = {
120
+ "ipv6_slaac",
121
+ "dhcpv6_stateful",
122
+ "dhcpv6_stateless",
123
+ "ipv6_prefix_delegation",
124
+ "ipv6_dns_aaaa",
125
+ "ipv6_tunneling",
126
+ "isatap",
127
+ "ospfv3",
128
+ "eigrp_ipv6",
129
+ "ripng",
130
+ "hsrp",
131
+ "dhcp_snooping",
132
+ "dai",
133
+ "dot1x",
134
+ "lldp",
135
+ "rep",
136
+ "snmp",
137
+ "netflow",
138
+ "span",
139
+ "qos",
140
+ "port_security",
141
+ "asa_acl_nat",
142
+ "asa_service_policy",
143
+ "clientless_vpn",
144
+ "cbac",
145
+ "zfw",
146
+ "sniffer",
147
+ "wlc",
148
+ "wpa_enterprise",
149
+ "wep",
150
+ "guest_wifi",
151
+ "beamforming",
152
+ "meraki",
153
+ "cellular_5g",
154
+ "bluetooth",
155
+ "network_controller",
156
+ "python_programming",
157
+ "javascript_programming",
158
+ "blockly_programming",
159
+ "tcp_udp_app",
160
+ "vm_iox",
161
+ "voip",
162
+ "ip_phone",
163
+ "call_manager",
164
+ "linksys_voice",
165
+ "mqtt",
166
+ "real_http",
167
+ "real_websocket",
168
+ "visual_scripting",
169
+ "ptp",
170
+ "profinet",
171
+ "l2nat",
172
+ "cyberobserver",
173
+ "industrial_firewall",
174
+ "coaxial",
175
+ "cable_dsl",
176
+ "central_office",
177
+ "cell_tower",
178
+ "power_distribution",
179
+ "hot_swappable",
180
+ "ios_license",
181
+ }
182
+
57
183
  TYPE_NORMALIZATION = {
58
184
  "pc": "PC",
59
185
  "pc-pt": "PC",
@@ -76,6 +202,9 @@ TYPE_NORMALIZATION = {
76
202
  "printer": "Printer",
77
203
  "smartphone": "Smartphone",
78
204
  "mcucomponent": "IoT",
205
+ "networkcontroller": "NetworkController",
206
+ "centralofficeserver": "CentralOfficeServer",
207
+ "celltower": "CellTower",
79
208
  }
80
209
 
81
210
  LICENSE_FILENAMES = [
@@ -215,6 +344,7 @@ def infer_capability_tags(item: dict[str, Any]) -> list[str]:
215
344
  rel = item.get("relative_path", "").lower().replace("\\", "/")
216
345
  rel_flat = rel.replace("/", " ")
217
346
  devices = item.get("devices", [])
347
+ raw_types = {str(device.get("type", "")).strip() for device in devices if str(device.get("type", "")).strip()}
218
348
  normalized_types = [normalize_device_type(device.get("type", "")) for device in devices]
219
349
  router_count = normalized_types.count("Router")
220
350
  switch_count = normalized_types.count("Switch")
@@ -232,6 +362,15 @@ def infer_capability_tags(item: dict[str, Any]) -> list[str]:
232
362
  if any(dtype in {"WirelessRouter", "LightWeightAccessPoint"} for dtype in normalized_types):
233
363
  tags.add("wireless")
234
364
  tags.add("wireless_ap")
365
+ if "HomeGateway" in raw_types or any(dtype == "IoT" for dtype in normalized_types):
366
+ tags.add("iot")
367
+ if any(dtype in {"ASA", "Security Appliance"} for dtype in normalized_types) or raw_types & {"ASA", "Security Appliance"}:
368
+ tags.add("security_edge")
369
+ tags.add("acl")
370
+ if any(dtype in {"Cloud", "Cable Modem", "Dsl Modem"} for dtype in normalized_types):
371
+ tags.add("wan")
372
+ if any(dtype == "MultiLayerSwitch" for dtype in normalized_types):
373
+ tags.add("multilayer_switching")
235
374
  if any(dtype in {"Tablet", "Laptop", "Smartphone"} for dtype in normalized_types):
236
375
  tags.add("wireless_client")
237
376
  if any(dtype == "Tablet" for dtype in normalized_types):
@@ -314,6 +453,23 @@ def infer_wireless_mode_tags(item: dict[str, Any]) -> list[str]:
314
453
  return sorted(tags)
315
454
 
316
455
 
456
+ def infer_iot_roles(item: dict[str, Any]) -> list[str]:
457
+ roles = {str(role).strip() for role in item.get("iot_roles", []) if str(role).strip()}
458
+ raw_types = {str(device.get("type", "")).strip() for device in item.get("devices", []) if str(device.get("type", "")).strip()}
459
+ normalized_types = {normalize_device_type(device.get("type", "")) for device in item.get("devices", []) if device.get("type")}
460
+ families = set(item.get("device_families", []) or infer_device_families(item))
461
+ has_thing = bool(raw_types & {"MCUComponent", "Board", "Sensor", "Actuator"}) or "iot devices" in families
462
+ has_gateway = "HomeGateway" in raw_types or ("home/wireless routers" in families and has_thing)
463
+ has_server = "Server" in normalized_types or "servers" in families
464
+ if has_thing:
465
+ roles.add("thing")
466
+ if has_gateway:
467
+ roles.add("gateway")
468
+ if has_server:
469
+ roles.add("server")
470
+ return sorted(roles)
471
+
472
+
317
473
  def infer_device_families(item: dict[str, Any]) -> list[str]:
318
474
  family_map = {
319
475
  "Router": "routers",
@@ -336,6 +492,9 @@ def infer_device_families(item: dict[str, Any]) -> list[str]:
336
492
  "Dsl Modem": "wan/cloud/dsl/cable devices",
337
493
  "Security Appliance": "security devices",
338
494
  "ASA": "security devices",
495
+ "NetworkController": "network controllers",
496
+ "CentralOfficeServer": "wan/cloud/dsl/cable devices",
497
+ "CellTower": "wan/cloud/dsl/cable devices",
339
498
  "IoT": "iot devices",
340
499
  "Sensor": "iot devices",
341
500
  "Actuator": "iot devices",
@@ -397,12 +556,24 @@ def infer_service_support(item: dict[str, Any]) -> list[str]:
397
556
 
398
557
  def infer_runtime_features(item: dict[str, Any]) -> list[str]:
399
558
  features: set[str] = set()
559
+ tags = set(item.get("capability_tags", []))
560
+ families = set(item.get("device_families", []) or infer_device_families(item))
400
561
  if item.get("wireless_mode_tags"):
401
562
  features.add("wireless_runtime")
402
563
  if item.get("service_support"):
403
564
  features.add("service_runtime")
404
- if item.get("iot_roles"):
565
+ if infer_iot_roles(item):
405
566
  features.add("iot_runtime")
567
+ if families & {"wan/cloud/dsl/cable devices"} or tags & {"wan", "ppp"}:
568
+ features.add("wan_runtime")
569
+ if families & {"security devices"} or tags & {"security_edge", "acl", "nat"}:
570
+ features.add("security_runtime")
571
+ if tags & {"vpn", "ipsec", "gre"}:
572
+ features.add("tunnel_runtime")
573
+ if tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
574
+ features.add("ipv6_runtime")
575
+ if families & {"multilayer switches"} or tags & {"multilayer_switching"}:
576
+ features.add("multilayer_runtime")
406
577
  if item.get("workspace_validation"):
407
578
  features.add("workspace_validated")
408
579
  if any(str(link.get("from", "")) and str(link.get("to", "")) for link in item.get("links", [])):
@@ -416,7 +587,7 @@ def infer_archetype_tags(item: dict[str, Any]) -> list[str]:
416
587
  families = set(item.get("device_families", []))
417
588
  services = set(item.get("service_support", []))
418
589
  wireless_modes = set(item.get("wireless_mode_tags", []))
419
- iot_roles = set(item.get("iot_roles", []))
590
+ iot_roles = set(infer_iot_roles(item))
420
591
 
421
592
  if topology & {"chain", "core_access", "department_lan", "router_on_a_stick"} or (
422
593
  families & {"routers", "switches", "multilayer switches"}
@@ -431,10 +602,29 @@ def infer_archetype_tags(item: dict[str, Any]) -> list[str]:
431
602
  tags.add("IoT/home gateway")
432
603
  if families & {"wan/cloud/dsl/cable devices", "security devices"} or set(item.get("capability_tags", [])) & {
433
604
  "vpn",
605
+ "ipsec",
606
+ "gre",
607
+ "ppp",
608
+ "wan",
609
+ "security_edge",
610
+ "multilayer_switching",
434
611
  "nat",
435
612
  "acl",
436
613
  }:
437
614
  tags.add("WAN/security edge")
615
+ capability_tags = set(item.get("capability_tags", []))
616
+ if capability_tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ipv6_prefix_delegation", "ipv6_dns_aaaa", "ipv6_tunneling", "isatap", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
617
+ tags.add("IPv6/routing")
618
+ if capability_tags & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"}:
619
+ tags.add("L2 security/monitoring")
620
+ if capability_tags & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"}:
621
+ tags.add("advanced wireless")
622
+ if capability_tags & {"network_controller", "python_programming", "javascript_programming", "blockly_programming", "tcp_udp_app", "vm_iox"} or "network controllers" in families:
623
+ tags.add("automation/controller")
624
+ if capability_tags & {"voip", "ip_phone", "call_manager", "linksys_voice"}:
625
+ tags.add("voice/collaboration")
626
+ if capability_tags & {"mqtt", "real_http", "real_websocket", "visual_scripting", "ptp", "profinet", "l2nat", "cyberobserver", "industrial_firewall"}:
627
+ tags.add("industrial IoT")
438
628
  return sorted(tags)
439
629
 
440
630
 
@@ -467,6 +657,8 @@ def infer_apply_safety_level(item: dict[str, Any]) -> str:
467
657
 
468
658
  def infer_validated_edit_capabilities(item: dict[str, Any]) -> list[str]:
469
659
  capabilities: set[str] = {str(tag) for tag in item.get("capability_tags", []) if str(tag).strip()}
660
+ inferred_iot_roles = set(infer_iot_roles(item))
661
+ families = set(item.get("device_families", []) or infer_device_families(item))
470
662
  service_capability_map = {
471
663
  "dhcp": "server_dhcp",
472
664
  "dns": "server_dns",
@@ -487,12 +679,24 @@ def infer_validated_edit_capabilities(item: dict[str, Any]) -> list[str]:
487
679
  capabilities.add("wireless_mutation")
488
680
  if any(device_family == "end devices" for device_family in item.get("device_families", [])):
489
681
  capabilities.add("end_device_mutation")
490
- if item.get("iot_roles"):
682
+ if inferred_iot_roles:
683
+ capabilities.add("iot")
491
684
  capabilities.add("iot_registration")
492
- if "server" in {str(role) for role in item.get("iot_roles", [])}:
685
+ if {"server", "gateway"} & inferred_iot_roles:
493
686
  capabilities.add("iot_control")
687
+ if capabilities & {"vpn", "ipsec", "gre"}:
688
+ capabilities.update({"vpn", "ipsec", "gre"})
689
+ if capabilities & {"ppp", "wan"} or families & {"wan/cloud/dsl/cable devices"}:
690
+ capabilities.add("wan")
691
+ if "ppp" in capabilities:
692
+ capabilities.add("ppp")
693
+ if capabilities & {"acl", "nat", "security_edge"} or families & {"security devices"}:
694
+ capabilities.add("security_edge")
695
+ if "multilayer switches" in families or "multilayer_switching" in capabilities:
696
+ capabilities.add("multilayer_switching")
494
697
  if item.get("apply_safety_level") == "inventory-supported":
495
698
  return []
699
+ capabilities -= REPORT_ONLY_CAPABILITIES
496
700
  return sorted(capabilities)
497
701
 
498
702
 
@@ -731,7 +935,8 @@ def enrich_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
731
935
  new_item.setdefault("model_families", infer_model_families(new_item))
732
936
  new_item.setdefault("port_media_types", infer_port_media_types(new_item))
733
937
  new_item.setdefault("service_support", infer_service_support(new_item))
734
- new_item.setdefault("runtime_features", infer_runtime_features(new_item))
938
+ new_item.setdefault("iot_roles", infer_iot_roles(new_item))
939
+ new_item["runtime_features"] = sorted(set(new_item.get("runtime_features", [])) | set(infer_runtime_features(new_item)))
735
940
  new_item.setdefault("donor_graph_fingerprint", infer_donor_graph_fingerprint(new_item))
736
941
  new_item.setdefault("apply_safety_level", infer_apply_safety_level(new_item))
737
942
  new_item.setdefault("archetype_tags", infer_archetype_tags(new_item))
@@ -1 +0,0 @@
1
-