packet-tracer-skill 0.2.2 → 0.2.3

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.
@@ -14,6 +14,16 @@ import zipfile
14
14
  from intent_parser import IntentPlan
15
15
 
16
16
  GITHUB_SEARCH_API = "https://api.github.com/search/repositories"
17
+ PERMISSIVE_LICENSES = {
18
+ "APACHE-2.0",
19
+ "BSD-2-CLAUSE",
20
+ "BSD-3-CLAUSE",
21
+ "CC0-1.0",
22
+ "ISC",
23
+ "MIT",
24
+ "UNLICENSE",
25
+ }
26
+ REMOTE_AUDIT_FILENAME = "remote-sample-audit.json"
17
27
 
18
28
 
19
29
  @dataclass
@@ -26,6 +36,25 @@ class RemoteSearchCandidate:
26
36
  import_status: str
27
37
  default_branch: str | None = None
28
38
  archive_url: str | None = None
39
+ candidate_promotion_status: str = "reference_only"
40
+ imported_file_count: int = 0
41
+ pkt_file_count: int = 0
42
+ pka_file_count: int = 0
43
+ readme_file_count: int = 0
44
+ license_file_count: int = 0
45
+
46
+
47
+ def is_permissive_license(license_or_permission: str | None) -> bool:
48
+ value = (license_or_permission or "").strip().upper()
49
+ return value in PERMISSIVE_LICENSES
50
+
51
+
52
+ def candidate_promotion_status(license_or_permission: str | None) -> str:
53
+ return "validated_curated" if is_permissive_license(license_or_permission) else "reference_only"
54
+
55
+
56
+ def candidate_is_curated_eligible(candidate: RemoteSearchCandidate) -> bool:
57
+ return candidate.candidate_promotion_status == "validated_curated"
29
58
 
30
59
 
31
60
  def build_remote_search_queries(plan: IntentPlan) -> list[str]:
@@ -91,6 +120,9 @@ def search_remote_candidates(
91
120
  import_status="discovered",
92
121
  default_branch=item.get("default_branch"),
93
122
  archive_url=item.get("archive_url"),
123
+ candidate_promotion_status=candidate_promotion_status(
124
+ str((item.get("license") or {}).get("spdx_id") or "unknown")
125
+ ),
94
126
  )
95
127
  )
96
128
  if len(candidates) >= max_results:
@@ -98,19 +130,66 @@ def search_remote_candidates(
98
130
  return candidates
99
131
 
100
132
 
133
+ def _allowed_archive_member(filename: str) -> bool:
134
+ lower_name = filename.lower()
135
+ return (
136
+ lower_name.endswith(".pkt")
137
+ or lower_name.endswith(".pka")
138
+ or lower_name.endswith("readme.md")
139
+ or lower_name.endswith("license")
140
+ or lower_name.endswith("license.md")
141
+ or lower_name.endswith("license.txt")
142
+ )
143
+
144
+
145
+ def _increment_file_counters(candidate: RemoteSearchCandidate, target: Path) -> None:
146
+ lower_name = target.name.lower()
147
+ candidate.imported_file_count += 1
148
+ if lower_name.endswith(".pkt"):
149
+ candidate.pkt_file_count += 1
150
+ elif lower_name.endswith(".pka"):
151
+ candidate.pka_file_count += 1
152
+ elif lower_name == "readme.md":
153
+ candidate.readme_file_count += 1
154
+ elif lower_name in {"license", "license.md", "license.txt"}:
155
+ candidate.license_file_count += 1
156
+
157
+
158
+ def _clear_import_destination(dest_root: Path) -> None:
159
+ if not dest_root.exists():
160
+ return
161
+ for child in dest_root.iterdir():
162
+ if child.is_dir():
163
+ for nested in child.rglob("*"):
164
+ if nested.is_file():
165
+ nested.unlink()
166
+ for nested_dir in sorted([item for item in child.rglob("*") if item.is_dir()], reverse=True):
167
+ nested_dir.rmdir()
168
+ child.rmdir()
169
+ else:
170
+ child.unlink()
171
+
172
+
101
173
  def auto_import_remote_candidates(
102
174
  candidates: list[RemoteSearchCandidate],
103
175
  import_cache_root: Path,
104
176
  *,
105
177
  max_results: int = 3,
178
+ dry_run: bool = False,
106
179
  ) -> list[RemoteSearchCandidate]:
107
180
  imported: list[RemoteSearchCandidate] = []
108
- import_cache_root.mkdir(parents=True, exist_ok=True)
181
+ if not dry_run:
182
+ import_cache_root.mkdir(parents=True, exist_ok=True)
109
183
  for candidate in candidates[:max_results]:
184
+ candidate.candidate_promotion_status = candidate_promotion_status(candidate.license_or_permission)
110
185
  repo_name = candidate.repo_url.rstrip("/").split("/")[-1]
111
186
  owner_name = candidate.repo_url.rstrip("/").split("/")[-2] if "/" in candidate.repo_url.rstrip("/") else "repo"
112
187
  dest_root = import_cache_root / f"{owner_name}_{repo_name}"
113
188
  archive_url = candidate.archive_url
189
+ if dry_run:
190
+ candidate.import_status = "dry_run"
191
+ imported.append(candidate)
192
+ continue
114
193
  if not archive_url:
115
194
  imported.append(candidate)
116
195
  continue
@@ -124,39 +203,26 @@ def auto_import_remote_candidates(
124
203
  continue
125
204
  try:
126
205
  with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
127
- if dest_root.exists():
128
- for child in dest_root.iterdir():
129
- if child.is_dir():
130
- for nested in child.rglob("*"):
131
- if nested.is_file():
132
- nested.unlink()
133
- for nested_dir in sorted([item for item in child.rglob("*") if item.is_dir()], reverse=True):
134
- nested_dir.rmdir()
135
- child.rmdir()
136
- else:
137
- child.unlink()
206
+ _clear_import_destination(dest_root)
138
207
  dest_root.mkdir(parents=True, exist_ok=True)
139
208
  for member in archive.infolist():
140
- lower_name = member.filename.lower()
141
- if not (
142
- lower_name.endswith(".pkt")
143
- or lower_name.endswith(".pka")
144
- or lower_name.endswith("readme.md")
145
- or lower_name.endswith("license")
146
- or lower_name.endswith("license.md")
147
- or lower_name.endswith("license.txt")
148
- ):
209
+ if not _allowed_archive_member(member.filename):
149
210
  continue
150
211
  cleaned = re.sub(r"^[^/]+/", "", member.filename).strip("/")
151
212
  if not cleaned:
152
213
  continue
153
214
  target = dest_root / cleaned
215
+ try:
216
+ target.resolve().relative_to(dest_root.resolve())
217
+ except ValueError:
218
+ continue
154
219
  target.parent.mkdir(parents=True, exist_ok=True)
155
220
  if member.is_dir():
156
221
  target.mkdir(parents=True, exist_ok=True)
157
222
  continue
158
223
  with archive.open(member) as source, target.open("wb") as sink:
159
224
  sink.write(source.read())
225
+ _increment_file_counters(candidate, target)
160
226
  candidate.path = str(dest_root)
161
227
  candidate.import_status = "imported"
162
228
  except zipfile.BadZipFile:
@@ -165,5 +231,115 @@ def auto_import_remote_candidates(
165
231
  return imported
166
232
 
167
233
 
234
+ def _decode_audit_for_root(root: Path, license_or_permission: str) -> dict[str, object]:
235
+ pkt_paths = sorted(root.rglob("*.pkt")) if root.exists() else []
236
+ decode_success_count = 0
237
+ decode_failure_count = 0
238
+ detected_features: set[str] = set()
239
+ detected_families: set[str] = set()
240
+ for pkt_path in pkt_paths:
241
+ try:
242
+ from sample_catalog import summarize_pkt_descriptor
243
+
244
+ descriptor = summarize_pkt_descriptor(
245
+ pkt_path,
246
+ str(pkt_path.relative_to(root)),
247
+ origin="external-reference",
248
+ prototype_eligible=False,
249
+ trust_level="reference-only",
250
+ role="reference",
251
+ license_or_permission=license_or_permission,
252
+ promotion_status="reference_only",
253
+ donor_eligible=False,
254
+ )
255
+ except Exception:
256
+ decode_failure_count += 1
257
+ continue
258
+ decode_success_count += 1
259
+ detected_features.update(descriptor.capability_tags)
260
+ detected_features.update(descriptor.validated_edit_capabilities)
261
+ detected_families.update(descriptor.archetype_tags)
262
+ detected_families.update(descriptor.device_families)
263
+ detected_families.update(descriptor.topology_tags)
264
+ return {
265
+ "decode_status": "not_run" if not pkt_paths else ("ok" if decode_failure_count == 0 else "partial_or_failed"),
266
+ "decode_success_count": decode_success_count,
267
+ "decode_failure_count": decode_failure_count,
268
+ "detected_feature_tags": sorted(detected_features),
269
+ "detected_feature_families": sorted(detected_families),
270
+ }
271
+
272
+
273
+ def build_remote_sample_audit(
274
+ candidates: list[RemoteSearchCandidate],
275
+ import_cache_root: Path,
276
+ ) -> dict[str, object]:
277
+ entries: list[dict[str, object]] = []
278
+ license_counts: dict[str, int] = {}
279
+ for candidate in candidates:
280
+ license_value = candidate.license_or_permission or "unknown"
281
+ license_counts[license_value] = license_counts.get(license_value, 0) + 1
282
+ entry = asdict(candidate)
283
+ if candidate.path:
284
+ entry.update(_decode_audit_for_root(Path(candidate.path), candidate.license_or_permission))
285
+ else:
286
+ entry.update(
287
+ {
288
+ "decode_status": "not_run",
289
+ "decode_success_count": 0,
290
+ "decode_failure_count": 0,
291
+ "detected_feature_tags": [],
292
+ "detected_feature_families": [],
293
+ }
294
+ )
295
+ if candidate.candidate_promotion_status == "validated_curated" and int(entry.get("decode_success_count", 0)) > 0:
296
+ entry["validation_promotion_status"] = "validated_curated"
297
+ else:
298
+ entry["validation_promotion_status"] = "reference_only"
299
+ entries.append(entry)
300
+ return {
301
+ "audit_version": "1.0",
302
+ "cache_root": str(import_cache_root),
303
+ "raw_pkt_policy": "remote Packet Tracer files stay local/cache-only and are not committed or packed",
304
+ "candidate_count": len(candidates),
305
+ "imported_repo_count": sum(1 for candidate in candidates if candidate.import_status == "imported"),
306
+ "dry_run_repo_count": sum(1 for candidate in candidates if candidate.import_status == "dry_run"),
307
+ "pkt_file_count": sum(candidate.pkt_file_count for candidate in candidates),
308
+ "pka_file_count": sum(candidate.pka_file_count for candidate in candidates),
309
+ "decode_success_count": sum(int(entry.get("decode_success_count", 0)) for entry in entries),
310
+ "decode_failure_count": sum(int(entry.get("decode_failure_count", 0)) for entry in entries),
311
+ "license_counts": license_counts,
312
+ "promotion_status_counts": {
313
+ status: sum(1 for candidate in candidates if candidate.candidate_promotion_status == status)
314
+ for status in sorted({candidate.candidate_promotion_status for candidate in candidates})
315
+ },
316
+ "validation_promotion_status_counts": {
317
+ status: sum(1 for entry in entries if entry.get("validation_promotion_status") == status)
318
+ for status in sorted({str(entry.get("validation_promotion_status")) for entry in entries})
319
+ },
320
+ "top_gaps_filled_by_imported_samples": sorted(
321
+ {
322
+ feature
323
+ for entry in entries
324
+ for feature in entry.get("detected_feature_tags", [])
325
+ if isinstance(feature, str)
326
+ }
327
+ )[:25],
328
+ "candidates": entries,
329
+ }
330
+
331
+
332
+ def write_remote_sample_audit(
333
+ candidates: list[RemoteSearchCandidate],
334
+ import_cache_root: Path,
335
+ audit_path: Path | None = None,
336
+ ) -> dict[str, object]:
337
+ payload = build_remote_sample_audit(candidates, import_cache_root)
338
+ target = audit_path or (import_cache_root / REMOTE_AUDIT_FILENAME)
339
+ target.parent.mkdir(parents=True, exist_ok=True)
340
+ target.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
341
+ return payload
342
+
343
+
168
344
  def asdict_list(candidates: list[RemoteSearchCandidate]) -> list[dict[str, object]]:
169
345
  return [asdict(candidate) for candidate in candidates]
@@ -33,13 +33,25 @@ CAPABILITY_KEYWORDS = {
33
33
  "isatap": ["isatap"],
34
34
  "dhcp_snooping": ["dhcp snooping", "option_82", "trusted_untrusted"],
35
35
  "ospf": ["ospf"],
36
+ "ospfv2": ["ospf", "ospfv2", "single-area ospf"],
36
37
  "ospfv3": ["ospfv3", "ipv6_ospf"],
37
38
  "eigrp": ["eigrp"],
39
+ "eigrp_ipv4": ["eigrp", "ipv4 eigrp"],
38
40
  "eigrp_ipv6": ["ipv6_eigrp"],
39
41
  "rip": ["rip"],
42
+ "ripv2": ["ripv2", "rip"],
40
43
  "ripng": ["ripng", "ipv6 rip"],
41
44
  "hsrp": ["hsrp"],
42
45
  "nat": ["nat"],
46
+ "nat_static": ["static nat"],
47
+ "nat_dynamic": ["dynamic nat"],
48
+ "pat": ["pat", "overload"],
49
+ "static_route": ["static route", "ip route"],
50
+ "default_route": ["default route"],
51
+ "dhcp_relay": ["dhcp relay", "helper-address"],
52
+ "ssh_ios": ["ssh"],
53
+ "ntp_ios": ["ntp"],
54
+ "syslog_ios": ["syslog"],
43
55
  "acl": ["acl", "access-list"],
44
56
  "dai": ["dai", "dynamic arp inspection"],
45
57
  "dot1x": ["dot1x", "802.1x", "port-based nac"],
@@ -47,9 +59,17 @@ CAPABILITY_KEYWORDS = {
47
59
  "rep": ["rep_"],
48
60
  "snmp": ["snmp"],
49
61
  "netflow": ["netflow"],
50
- "span": ["span", "rspan"],
62
+ "span": ["rspan", "monitor session", "span session"],
51
63
  "qos": ["qos"],
52
64
  "port_security": ["port security", "port-security"],
65
+ "bgp": ["bgp"],
66
+ "stp": ["stp", "spanning tree", "spanning-tree"],
67
+ "rstp": ["rstp", "rapid-pvst"],
68
+ "etherchannel": ["etherchannel", "port-channel", "channel-group"],
69
+ "lacp": ["lacp"],
70
+ "pagp": ["pagp"],
71
+ "vtp": ["vtp"],
72
+ "dtp": ["dtp"],
53
73
  "vpn": ["vpn", "ipsec", "gre"],
54
74
  "ipsec": ["ipsec", "ike"],
55
75
  "gre": ["gre"],
@@ -138,6 +158,26 @@ REPORT_ONLY_CAPABILITIES = {
138
158
  "span",
139
159
  "qos",
140
160
  "port_security",
161
+ "bgp",
162
+ "stp",
163
+ "rstp",
164
+ "etherchannel",
165
+ "lacp",
166
+ "pagp",
167
+ "vtp",
168
+ "dtp",
169
+ "ospfv2",
170
+ "eigrp_ipv4",
171
+ "ripv2",
172
+ "static_route",
173
+ "default_route",
174
+ "dhcp_relay",
175
+ "nat_static",
176
+ "nat_dynamic",
177
+ "pat",
178
+ "ssh_ios",
179
+ "ntp_ios",
180
+ "syslog_ios",
141
181
  "asa_acl_nat",
142
182
  "asa_service_policy",
143
183
  "clientless_vpn",
@@ -422,8 +462,12 @@ def infer_preferred_roles(item: dict[str, Any]) -> list[str]:
422
462
  roles.append("preferred_vlan")
423
463
  if "dhcp_pool" in tags:
424
464
  roles.append("preferred_dhcp")
425
- if {"ospf", "eigrp", "rip"} & tags:
465
+ if {"ospf", "eigrp", "rip", "bgp", "ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route"} & tags:
426
466
  roles.append("preferred_routing")
467
+ if {"dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"} & tags:
468
+ roles.append("preferred_ipv4_management")
469
+ if {"stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"} & tags:
470
+ roles.append("preferred_l2_resiliency")
427
471
  if {"nat", "acl", "vpn"} & tags:
428
472
  roles.append("preferred_security")
429
473
  if {"vpn", "ipsec", "gre", "ppp", "multilayer_switching"} & tags:
@@ -481,6 +525,9 @@ def infer_device_families(item: dict[str, Any]) -> list[str]:
481
525
  "Tablet": "end devices",
482
526
  "Smartphone": "end devices",
483
527
  "Printer": "end devices",
528
+ "IpPhone": "end devices",
529
+ "HomeVoip": "end devices",
530
+ "AnalogPhone": "end devices",
484
531
  "LightWeightAccessPoint": "access points",
485
532
  "WirelessRouter": "home/wireless routers",
486
533
  "WirelessRouterNewGeneration": "home/wireless routers",
@@ -572,6 +619,10 @@ def infer_runtime_features(item: dict[str, Any]) -> list[str]:
572
619
  features.add("tunnel_runtime")
573
620
  if tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
574
621
  features.add("ipv6_runtime")
622
+ if tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
623
+ features.add("l2_resiliency_runtime")
624
+ if tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
625
+ features.add("ipv4_routing_management_runtime")
575
626
  if families & {"multilayer switches"} or tags & {"multilayer_switching"}:
576
627
  features.add("multilayer_runtime")
577
628
  if item.get("workspace_validation"):
@@ -617,6 +668,10 @@ def infer_archetype_tags(item: dict[str, Any]) -> list[str]:
617
668
  tags.add("IPv6/routing")
618
669
  if capability_tags & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"}:
619
670
  tags.add("L2 security/monitoring")
671
+ if capability_tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
672
+ tags.add("L2 resiliency/routing")
673
+ if capability_tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
674
+ tags.add("IPv4 routing/management")
620
675
  if capability_tags & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"}:
621
676
  tags.add("advanced wireless")
622
677
  if capability_tags & {"network_controller", "python_programming", "javascript_programming", "blockly_programming", "tcp_udp_app", "vm_iox"} or "network controllers" in families: