packet-tracer-skill 0.2.3 → 0.3.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 (53) hide show
  1. package/CHANGELOG.md +424 -73
  2. package/README.md +557 -442
  3. package/SKILL.md +337 -262
  4. package/bin/packet-tracer-skill.js +29 -2
  5. package/docs/github-launch-ops-0.2.3.md +37 -0
  6. package/docs/github-metadata.md +6 -4
  7. package/docs/hero-demo-plan.md +1 -1
  8. package/docs/home-iot-donor-proof.md +4 -4
  9. package/docs/l2-security-qos-proof.md +1 -1
  10. package/docs/packet-tracer-feature-gap-atlas.md +4 -4
  11. package/docs/post-launch-follow-up.md +9 -5
  12. package/docs/proof-readiness-dashboard.md +69 -0
  13. package/docs/publish-preview-roadmap.md +6 -5
  14. package/docs/release-checklist.md +17 -8
  15. package/docs/release-notes-0.2.4.md +20 -0
  16. package/docs/runtime-truth.md +33 -8
  17. package/docs/security-edge-deepening-proof.md +1 -1
  18. package/examples/README.md +98 -69
  19. package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
  20. package/examples/gallery.md +94 -6
  21. package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
  22. package/examples/index.json +932 -4
  23. package/examples/local-sample-evidence.json +24 -0
  24. package/examples/proof-cards.json +117 -0
  25. package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
  26. package/package.json +60 -53
  27. package/pytest.ini +9 -0
  28. package/references/packettracer-sample-catalog.json +45287 -4525
  29. package/references/packettracer-sample-catalog.md +599 -259
  30. package/references/proof-readiness-candidates.json +352 -0
  31. package/scripts/build_examples_index.py +228 -35
  32. package/scripts/build_sample_catalog.py +24 -44
  33. package/scripts/corpus_runner.py +430 -0
  34. package/scripts/coverage_matrix.py +1842 -1812
  35. package/scripts/donor_cache.py +354 -0
  36. package/scripts/donor_diagnostics.py +3 -1
  37. package/scripts/generate_pkt.py +8762 -4228
  38. package/scripts/intent_parser.py +2242 -1657
  39. package/scripts/local_donors.py +340 -0
  40. package/scripts/packet_tracer_env.py +846 -391
  41. package/scripts/pkt_annotate.py +218 -0
  42. package/scripts/pkt_codec.py +420 -181
  43. package/scripts/pkt_editor.py +2405 -1703
  44. package/scripts/pkt_transformer.py +1072 -727
  45. package/scripts/pkt_verify.py +461 -0
  46. package/scripts/runtime_doctor.py +80 -29
  47. package/scripts/sample_catalog.py +1372 -1250
  48. package/scripts/twofish_diagnostics.py +48 -31
  49. package/scripts/usage_ledger.py +218 -0
  50. package/scripts/vendor/README.md +44 -37
  51. package/scripts/vendor/twofish_pure.py +321 -0
  52. package/scripts/workspace_repair.py +548 -508
  53. package/templates/pt900/donors/README.md +15 -0
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env python3
2
+ """A local cache of donor labs, so generation stops needing Packet Tracer installed.
3
+
4
+ The codec is self-contained, but generation still reads device and link
5
+ prototypes out of real Packet Tracer saves. That made a local install a hard
6
+ requirement for CI, servers, and any machine other than the one where labs are
7
+ authored.
8
+
9
+ The cache is deliberately shaped like a Packet Tracer `saves/` tree, using the
10
+ same relative paths. `resolve_sample_path()` and `require_packet_tracer_saves_root()`
11
+ are already the single seams every consumer goes through, so a cache that
12
+ answers those seams needs no changes anywhere downstream. A separate "where do
13
+ samples live" model would be a second source of truth, which is the defect shape
14
+ this repo has hit repeatedly.
15
+
16
+ Nothing is redistributed: the cache is populated from the user's own licensed
17
+ install and never leaves the machine.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import shutil
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+
29
+ MANIFEST_VERSION = 1
30
+ MANIFEST_NAME = "manifest.json"
31
+ COVERAGE_DIR = "_donors"
32
+ DEFAULT_COVERAGE_LIMIT = 3
33
+
34
+ # Samples the generation and edit paths name directly. Without these a machine
35
+ # with no Packet Tracer cannot build links, servers, or wireless clients.
36
+ REQUIRED_PROTOTYPE_SAMPLES: tuple[str, ...] = (
37
+ r"01 Networking\FTP\FTP.pkt",
38
+ r"01 Networking\DNS\Multilevel_DNS.pkt",
39
+ r"01 Networking\DHCP\dhcp_reservation.pkt",
40
+ )
41
+
42
+ # Families worth having a donor for beyond what the prototype samples cover.
43
+ COVERAGE_FAMILIES: tuple[str, ...] = ("Router", "Switch", "PC", "Server")
44
+
45
+
46
+ @dataclass
47
+ class BootstrapReport:
48
+ source_root: str = ""
49
+ cached_paths: list[str] = field(default_factory=list)
50
+ coverage_families: list[str] = field(default_factory=list)
51
+ skipped: list[str] = field(default_factory=list)
52
+ bytes_written: int = 0
53
+ error: str = ""
54
+
55
+ @property
56
+ def ok(self) -> bool:
57
+ return not self.error and bool(self.cached_paths)
58
+
59
+ def summary(self) -> str:
60
+ if self.error:
61
+ return f"donor cache not written: {self.error}"
62
+ families = ", ".join(self.coverage_families) or "none"
63
+ return (
64
+ f"Cached {len(self.cached_paths)} donor sample(s) "
65
+ f"({self.bytes_written / 1024:.0f} KB, families: {families}) in {cache_root()}. "
66
+ "Generation no longer needs Packet Tracer on this machine."
67
+ )
68
+
69
+
70
+ def cache_enabled() -> bool:
71
+ return (os.getenv("PKT_DONOR_CACHE") or "").strip().lower() not in {"off", "0", "false", "none"}
72
+
73
+
74
+ def cache_root() -> Path:
75
+ override = (os.getenv("PKT_DONOR_CACHE") or "").strip()
76
+ if override and override.lower() not in {"off", "0", "false", "none", "on", "1", "true"}:
77
+ return Path(override).expanduser()
78
+ return Path.home() / ".pkt" / "saves"
79
+
80
+
81
+ def manifest_path() -> Path:
82
+ return cache_root() / MANIFEST_NAME
83
+
84
+
85
+ def read_manifest() -> dict | None:
86
+ try:
87
+ data = json.loads(manifest_path().read_text(encoding="utf-8"))
88
+ except (OSError, json.JSONDecodeError):
89
+ return None
90
+ return data if isinstance(data, dict) else None
91
+
92
+
93
+ def cache_is_usable(target_version: str | None = None) -> bool:
94
+ """True when the manifest is valid and every file it lists still exists."""
95
+ if not cache_enabled():
96
+ return False
97
+ manifest = read_manifest()
98
+ if not manifest or manifest.get("manifest_version") != MANIFEST_VERSION:
99
+ return False
100
+ cached_target = str(manifest.get("target_version") or "")
101
+ if target_version and cached_target:
102
+ # Compare by compatibility tier, not string equality. The detected target
103
+ # legitimately differs between a machine with Packet Tracer installed
104
+ # (`9.0.0`, from the install directory) and one without it (`9.0.0.0810`,
105
+ # from the donor's own VERSION). Exact matching here would discard a
106
+ # perfectly good cache on exactly the machine it exists to serve — the
107
+ # same mistake the donor gate itself used to make.
108
+ from packet_tracer_env import donor_compatibility
109
+
110
+ # Deliberately not `donor_tier_is_accepted`: that follows the donor
111
+ # policy, which is `exact` because a generation *base* must be a lab the
112
+ # running install wrote. This check answers a different question — is the
113
+ # cache from the same release line — so it accepts `same_minor` outright.
114
+ if donor_compatibility(cached_target, target_version) not in {"exact", "same_minor"}:
115
+ return False
116
+ entries = manifest.get("cached_paths") or []
117
+ if not entries:
118
+ return False
119
+ root = cache_root()
120
+ return all((root / str(entry)).exists() for entry in entries)
121
+
122
+
123
+ def _copy_into_cache(source: Path, relative_path: str, report: BootstrapReport) -> None:
124
+ destination = cache_root() / relative_path
125
+ destination.parent.mkdir(parents=True, exist_ok=True)
126
+ shutil.copy2(source, destination)
127
+ report.cached_paths.append(relative_path)
128
+ report.bytes_written += destination.stat().st_size
129
+
130
+
131
+ HOST_FAMILIES = {"PC", "Server", "Printer", "Laptop", "Tablet", "Smartphone"}
132
+
133
+
134
+ def _access_score(devices: list, links: list) -> int:
135
+ """How many hosts hang off switches in this lab.
136
+
137
+ Donor-prune keeps a switch and renames the hosts attached to it, so the
138
+ useful measure of a donor is its access structure, not its raw device count.
139
+ A 33-device industrial lab whose switches carry no PCs is a worse donor than
140
+ a small campus lab with six hosts on a switch — exactly the mistake an
141
+ earlier version of this selection made.
142
+ """
143
+ from sample_catalog import normalize_device_type
144
+
145
+ kind = {str(device.get("name", "")): normalize_device_type(str(device.get("type", ""))) for device in devices}
146
+ host_links = 0
147
+ router_uplinks = 0
148
+ for link in links:
149
+ left, right = str(link.get("from") or ""), str(link.get("to") or "")
150
+ if not left or not right:
151
+ continue
152
+ pair = {kind.get(left), kind.get(right)}
153
+ if "Switch" in pair and pair & HOST_FAMILIES:
154
+ host_links += 1
155
+ elif "Switch" in pair and "Router" in pair:
156
+ router_uplinks += 1
157
+ # Hosts dominate; a router uplink is worth having but only once or twice.
158
+ # Weighting every uplink equally let a four-router lab with a single PC
159
+ # outrank a campus lab with six hosts on a switch.
160
+ return host_links + min(router_uplinks, 2)
161
+
162
+
163
+ def _local_saved_labs(target_version: str | None, limit: int) -> list[Path]:
164
+ """Labs this Packet Tracer wrote, richest access structure first.
165
+
166
+ These are the only files usable as a generation base: their `<VERSION>` is
167
+ the running build, so Packet Tracer opens what is built from them.
168
+ """
169
+ from packet_tracer_env import DEFAULT_DONOR_FALLBACKS, _pkt_version
170
+ from pkt_codec import decode_pkt_auto, parse_pkt_xml
171
+ from sample_catalog import normalize_device_type
172
+
173
+ scored: list[tuple[int, Path]] = []
174
+ for directory in DEFAULT_DONOR_FALLBACKS:
175
+ if not directory.exists():
176
+ continue
177
+ try:
178
+ candidates = sorted(directory.glob("*.pkt"))[:20]
179
+ except OSError:
180
+ continue
181
+ for candidate in candidates:
182
+ if target_version and _pkt_version(candidate) != target_version:
183
+ continue
184
+ try:
185
+ root = parse_pkt_xml(decode_pkt_auto(candidate.read_bytes())[0])
186
+ except Exception:
187
+ continue
188
+ devices = [
189
+ {
190
+ "name": device.findtext("./ENGINE/NAME") or "",
191
+ "type": normalize_device_type(device.findtext("./ENGINE/TYPE") or ""),
192
+ }
193
+ for device in root.findall(".//DEVICES/DEVICE")
194
+ ]
195
+ ref_to_name = {
196
+ device.findtext("./ENGINE/SAVE_REF_ID") or "": device.findtext("./ENGINE/NAME") or ""
197
+ for device in root.findall(".//DEVICES/DEVICE")
198
+ }
199
+ links = []
200
+ for link in root.findall(".//LINKS/LINK"):
201
+ cable = link.find("./CABLE")
202
+ if cable is None:
203
+ continue
204
+ links.append(
205
+ {
206
+ "from": ref_to_name.get(cable.findtext("FROM") or "", ""),
207
+ "to": ref_to_name.get(cable.findtext("TO") or "", ""),
208
+ }
209
+ )
210
+ scored.append((_access_score(devices, links), candidate))
211
+
212
+ scored.sort(key=lambda item: (-item[0], item[1].name))
213
+ return [path for _, path in scored[:limit]]
214
+
215
+
216
+ def select_coverage_donors(
217
+ catalog: list,
218
+ target_version: str | None,
219
+ limit: int = DEFAULT_COVERAGE_LIMIT,
220
+ ) -> list[str]:
221
+ """Pick the best-structured version-compatible lab covering each device family.
222
+
223
+ The catalogue already records devices, version and capability tags, so this
224
+ reads existing evidence rather than scanning the install again.
225
+ """
226
+ from packet_tracer_env import donor_compatibility, donor_tier_is_accepted
227
+ from sample_catalog import normalize_device_type
228
+
229
+ eligible = []
230
+ for sample in catalog:
231
+ version = getattr(sample, "version", "") or ""
232
+ if target_version and not donor_tier_is_accepted(donor_compatibility(version, target_version)):
233
+ continue
234
+ devices = getattr(sample, "devices", [])
235
+ families = {normalize_device_type(str(device.get("type", ""))) for device in devices}
236
+ eligible.append(
237
+ (
238
+ getattr(sample, "relative_path", ""),
239
+ families,
240
+ _access_score(devices, getattr(sample, "links", [])),
241
+ )
242
+ )
243
+
244
+ chosen: list[str] = []
245
+ covered: set[str] = set()
246
+ for family in COVERAGE_FAMILIES:
247
+ if family in covered or len(chosen) >= limit:
248
+ continue
249
+ candidates = [item for item in eligible if family in item[1] and item[0] not in chosen]
250
+ if not candidates:
251
+ continue
252
+ # Best access structure first. A donor exists to be pruned *from*, and
253
+ # what gets reused is a switch with hosts on it, so rank by that rather
254
+ # than by device count or file size.
255
+ relative_path, families, _ = max(
256
+ candidates,
257
+ key=lambda item: (item[2], len(item[1] & set(COVERAGE_FAMILIES)), item[0]),
258
+ )
259
+ chosen.append(relative_path)
260
+ covered |= families
261
+ return chosen
262
+
263
+
264
+ def bootstrap(
265
+ saves_root: Path,
266
+ catalog: list | None = None,
267
+ target_version: str | None = None,
268
+ limit: int = DEFAULT_COVERAGE_LIMIT,
269
+ ) -> BootstrapReport:
270
+ """Populate the cache from a live Packet Tracer install.
271
+
272
+ Never raises: a cache that cannot be written must not fail a generation that
273
+ would otherwise succeed.
274
+ """
275
+ report = BootstrapReport(source_root=str(saves_root))
276
+ if not cache_enabled():
277
+ report.error = "disabled by PKT_DONOR_CACHE"
278
+ return report
279
+
280
+ try:
281
+ cache_root().mkdir(parents=True, exist_ok=True)
282
+ except OSError as exc:
283
+ report.error = f"cache directory is not writable: {exc}"
284
+ return report
285
+
286
+ try:
287
+ for relative_path in REQUIRED_PROTOTYPE_SAMPLES:
288
+ source = saves_root / relative_path
289
+ if source.exists():
290
+ _copy_into_cache(source, relative_path, report)
291
+ else:
292
+ report.skipped.append(relative_path)
293
+
294
+ # Coverage donors must be labs the running install actually wrote.
295
+ # Bundled Cisco samples carry other builds and Packet Tracer refuses to
296
+ # open anything generated from them — relabelling the output does not
297
+ # help, because the donor's structures were never migrated.
298
+ for source in _local_saved_labs(target_version, limit):
299
+ destination_relative = str(Path(COVERAGE_DIR) / source.name)
300
+ _copy_into_cache(source, destination_relative, report)
301
+ report.coverage_families.append(source.stem)
302
+ if not report.coverage_families:
303
+ report.skipped.append(
304
+ f"no locally saved lab at build {target_version} was found to cache as a donor"
305
+ )
306
+
307
+ manifest = {
308
+ "manifest_version": MANIFEST_VERSION,
309
+ "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
310
+ "source_root": str(saves_root),
311
+ "target_version": target_version or "",
312
+ "cached_paths": report.cached_paths,
313
+ "skipped": report.skipped,
314
+ }
315
+ manifest_path().write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
316
+ except OSError as exc:
317
+ report.error = str(exc)
318
+ return report
319
+
320
+
321
+ def missing_requirements_message() -> str:
322
+ """What to tell a user who has neither Packet Tracer nor a cache."""
323
+ required = "\n ".join(REQUIRED_PROTOTYPE_SAMPLES)
324
+ return (
325
+ "No Packet Tracer install and no donor cache were found. Generation needs real "
326
+ "Packet Tracer saves for device and link prototypes:\n "
327
+ f"{required}\n"
328
+ f"Run any generate command once on a machine that has Packet Tracer to populate "
329
+ f"{cache_root()}, then copy that folder here. Set PACKET_TRACER_SAVES_ROOT to "
330
+ "point at an existing saves tree instead."
331
+ )
332
+
333
+
334
+ def cache_status(target_version: str | None = None) -> dict[str, object]:
335
+ """Cache state for `--doctor`."""
336
+ manifest = read_manifest()
337
+ return {
338
+ "enabled": cache_enabled(),
339
+ "root": str(cache_root()),
340
+ "usable": cache_is_usable(target_version),
341
+ "cached_file_count": len(manifest.get("cached_paths", [])) if manifest else 0,
342
+ "created_at": manifest.get("created_at", "") if manifest else "",
343
+ "source_root": manifest.get("source_root", "") if manifest else "",
344
+ "target_version": manifest.get("target_version", "") if manifest else "",
345
+ }
346
+
347
+
348
+ def main() -> int:
349
+ print(json.dumps(cache_status(), ensure_ascii=False, indent=2))
350
+ return 0
351
+
352
+
353
+ if __name__ == "__main__":
354
+ raise SystemExit(main())
@@ -4,7 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import json
7
- from packet_tracer_env import inspect_packet_tracer_compatibility_donor
7
+ from packet_tracer_env import get_donor_policy, inspect_packet_tracer_compatibility_donor
8
8
 
9
9
 
10
10
  def collect_donor_diagnostics() -> dict[str, object]:
@@ -15,6 +15,8 @@ def collect_donor_diagnostics() -> dict[str, object]:
15
15
  "donor_path": str(details.resolved_path) if details.resolved_path else "",
16
16
  "donor_version": details.donor_version or "",
17
17
  "donor_source": details.donor_source or "",
18
+ "donor_policy": get_donor_policy(),
19
+ "compatibility_tier": details.compatibility_tier,
18
20
  "status": details.status,
19
21
  "message": (
20
22
  f"{details.resolved_path} (version {details.donor_version})"