packet-tracer-skill 0.2.2 → 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 (66) hide show
  1. package/CHANGELOG.md +424 -42
  2. package/README.md +535 -250
  3. package/SKILL.md +337 -262
  4. package/bin/packet-tracer-skill.js +29 -2
  5. package/docs/automation-controller-proof.md +35 -0
  6. package/docs/curated-donor-registry.md +11 -0
  7. package/docs/generate-ready-pilot-design.md +30 -0
  8. package/docs/github-launch-ops-0.2.3.md +37 -0
  9. package/docs/github-metadata.md +6 -4
  10. package/docs/hero-demo-plan.md +1 -1
  11. package/docs/home-iot-donor-proof.md +4 -4
  12. package/docs/industrial-programming-proof.md +48 -0
  13. package/docs/ipv4-routing-management-proof.md +37 -0
  14. package/docs/l2-resiliency-bgp-proof.md +60 -0
  15. package/docs/l2-security-qos-proof.md +59 -0
  16. package/docs/packet-tracer-feature-gap-atlas.md +174 -17
  17. package/docs/post-launch-follow-up.md +9 -5
  18. package/docs/proof-readiness-dashboard.md +69 -0
  19. package/docs/publish-preview-roadmap.md +6 -5
  20. package/docs/release-checklist.md +27 -13
  21. package/docs/release-notes-0.2.2.md +1 -1
  22. package/docs/release-notes-0.2.3.md +59 -0
  23. package/docs/release-notes-0.2.4.md +20 -0
  24. package/docs/runtime-truth.md +33 -8
  25. package/docs/security-edge-deepening-proof.md +65 -0
  26. package/docs/voice-collaboration-proof.md +38 -0
  27. package/docs/wan-security-donor-proof.md +20 -3
  28. package/examples/README.md +98 -69
  29. package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
  30. package/examples/gallery.md +94 -6
  31. package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
  32. package/examples/index.json +932 -4
  33. package/examples/local-sample-evidence.json +24 -0
  34. package/examples/proof-cards.json +117 -0
  35. package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
  36. package/package.json +60 -44
  37. package/pytest.ini +9 -0
  38. package/references/packettracer-feature-atlas.json +67 -17
  39. package/references/packettracer-sample-catalog.json +45287 -4525
  40. package/references/packettracer-sample-catalog.md +599 -259
  41. package/references/proof-readiness-candidates.json +352 -0
  42. package/scripts/build_examples_index.py +228 -35
  43. package/scripts/build_sample_catalog.py +24 -44
  44. package/scripts/corpus_runner.py +430 -0
  45. package/scripts/coverage_matrix.py +1842 -1319
  46. package/scripts/donor_cache.py +354 -0
  47. package/scripts/donor_diagnostics.py +3 -1
  48. package/scripts/feature_atlas.py +65 -1
  49. package/scripts/generate_pkt.py +8762 -4070
  50. package/scripts/intent_parser.py +2242 -1138
  51. package/scripts/local_donors.py +340 -0
  52. package/scripts/packet_tracer_env.py +846 -391
  53. package/scripts/pkt_annotate.py +218 -0
  54. package/scripts/pkt_codec.py +420 -181
  55. package/scripts/pkt_editor.py +2405 -1226
  56. package/scripts/pkt_transformer.py +1072 -727
  57. package/scripts/pkt_verify.py +461 -0
  58. package/scripts/remote_search.py +197 -21
  59. package/scripts/runtime_doctor.py +80 -29
  60. package/scripts/sample_catalog.py +1372 -1195
  61. package/scripts/twofish_diagnostics.py +48 -31
  62. package/scripts/usage_ledger.py +218 -0
  63. package/scripts/vendor/README.md +44 -37
  64. package/scripts/vendor/twofish_pure.py +321 -0
  65. package/scripts/workspace_repair.py +548 -508
  66. package/templates/pt900/donors/README.md +15 -0
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ """Index every local lab that could serve as a generation base, not just one.
3
+
4
+ The base-donor pool used to be a single file. `_compat_donor_candidate()`
5
+ resolves one compatibility donor, the bundled Cisco samples are all rejected by
6
+ the exact-build policy, and curated donor roots are empty unless someone passes
7
+ `--donor-root`. So whatever that one donor happened to lack, the skill declared
8
+ impossible.
9
+
10
+ Measured on this machine: 143 local labs carry the running build, several of
11
+ them with wireless routers, access points, laptops and IP phones -- while the
12
+ chosen donor was a wired campus lab. `1 wireless router 2 laptop qur` was
13
+ reported as a donor limitation. It was a donor *selection* limitation.
14
+
15
+ Indexing them is not free: a full summary is ~770 ms per lab, so 143 labs cost
16
+ ~110 s, against a 5-7 s generation. The index is therefore cached on disk and
17
+ keyed by size and mtime, which makes a warm run cost little more than a `stat`
18
+ per file. Version is read with `peek_pkt_header` (~13 ms) before the expensive
19
+ summary, so labs on the wrong build never get decoded at all.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ from packet_tracer_env import (
31
+ donor_compatibility,
32
+ donor_tier_is_accepted,
33
+ get_donor_policy,
34
+ get_packet_tracer_target_version,
35
+ )
36
+ from pkt_codec import peek_pkt_header
37
+
38
+ DEFAULT_INDEX_PATH = Path.home() / ".pkt" / "local-donor-index.json"
39
+ DEFAULT_SEARCH_ROOTS = [
40
+ Path.home() / "Downloads",
41
+ Path.home() / "Documents",
42
+ Path.home() / "Desktop",
43
+ ]
44
+ # A ceiling on how much of a lab collection to consider. Ranking gets no better
45
+ # past a few dozen candidates, and the first-run indexing cost is linear.
46
+ DEFAULT_SCAN_LIMIT = 400
47
+
48
+ _VERSION_PATTERN = re.compile(rb"<VERSION>([^<]*)</VERSION>")
49
+
50
+ # `Documents` on this machine holds 414,000 entries and takes 12 s to walk
51
+ # recursively -- twice a whole generation run, spent almost entirely inside
52
+ # checkouts and dependency trees. Saved labs live at the top of a folder or one
53
+ # or two levels down, so the walk is bounded instead of exhaustive.
54
+ DEFAULT_MAX_DEPTH = 3
55
+ SKIPPED_DIRECTORY_NAMES = {
56
+ ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
57
+ "env", ".tox", "site-packages", "dist", "build", ".cache", ".pytest_cache",
58
+ "AppData", ".idea", ".vscode",
59
+ # Generated labs must never become donors. Without this the skill happily
60
+ # picked its own `output/` files as a base -- a lab derived from a lab,
61
+ # carrying every simplification the first pass made.
62
+ "output", "outputs", "scratchpad", "tmp", "temp", ".pkt-cache",
63
+ }
64
+
65
+
66
+ def _iter_pkt_files(root: Path, max_depth: int) -> list[Path]:
67
+ """`.pkt` files within `max_depth` levels of `root`, skipping code trees."""
68
+ found: list[Path] = []
69
+ frontier = [(root, 0)]
70
+ while frontier:
71
+ directory, depth = frontier.pop()
72
+ try:
73
+ entries = list(directory.iterdir())
74
+ except OSError:
75
+ continue
76
+ for entry in entries:
77
+ try:
78
+ if entry.is_dir():
79
+ if depth + 1 <= max_depth and entry.name not in SKIPPED_DIRECTORY_NAMES:
80
+ frontier.append((entry, depth + 1))
81
+ elif entry.suffix.lower() == ".pkt":
82
+ found.append(entry)
83
+ except OSError:
84
+ continue
85
+ return sorted(found)
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class LocalDonor:
90
+ path: Path
91
+ version: str
92
+ device_counts: dict[str, int] | None = None
93
+
94
+
95
+ def _device_counts_of(path: Path) -> dict[str, int]:
96
+ """Device types a lab contains. Requires a full decode, so cache the result.
97
+
98
+ `__serial_routers__` rides along under a name no Packet Tracer type can
99
+ collide with. A serial WAN cannot be built from a donor whose routers have
100
+ no serial ports, and that is not visible from the type names alone -- the
101
+ ports live on an installed module. Counting it here means the index pays for
102
+ the decode once and every later lookup is a dictionary read.
103
+ """
104
+ from collections import Counter
105
+
106
+ from pkt_codec import decode_pkt_auto, parse_pkt_xml
107
+ from pkt_transformer import port_capacity
108
+
109
+ try:
110
+ xml, _container = decode_pkt_auto(path.read_bytes(), verify=False)
111
+ root = parse_pkt_xml(xml)
112
+ except Exception: # noqa: BLE001 - unreadable labs are simply not donors
113
+ return {}
114
+ counts = Counter(
115
+ (device.findtext("./ENGINE/TYPE", default="") or "").strip()
116
+ for device in root.findall(".//DEVICES/DEVICE")
117
+ )
118
+ result = {kind: count for kind, count in counts.items() if kind}
119
+ result["__serial_routers__"] = sum(
120
+ 1
121
+ for device in root.findall(".//DEVICES/DEVICE")
122
+ if (device.findtext("./ENGINE/TYPE", default="") or "").strip() == "Router"
123
+ and port_capacity(device).get("Serial", 0) > 0
124
+ )
125
+ return result
126
+
127
+
128
+ # Packet Tracer's own type names do not match the words a prompt uses, and a
129
+ # request for a laptop is served just as well by any of these.
130
+ TYPE_EQUIVALENTS: dict[str, tuple[str, ...]] = {
131
+ "WirelessRouter": ("WirelessRouter", "WirelessRouterNewGeneration", "LinksysWRT300N"),
132
+ "Laptop": ("Laptop", "WirelessEndDevice", "LaptopPT"),
133
+ "Tablet": ("Tablet", "TabletPC", "Pda"),
134
+ "LightWeightAccessPoint": ("LightWeightAccessPoint", "AccessPoint", "AccessPointPT"),
135
+ "PC": ("PC", "Pc", "PcPT"),
136
+ "Server": ("Server", "ServerPT"),
137
+ "Switch": ("Switch", "MultiLayerSwitch"),
138
+ "Smartphone": ("Smartphone", "WirelessEndDevice"),
139
+ "MCUComponent": ("MCUComponent", "Thing", "IoE", "SBC", "MCU"),
140
+ "Thing": ("Thing", "MCUComponent", "IoE"),
141
+ }
142
+
143
+
144
+ def covers_requested_types(counts: dict[str, int], required: dict[str, int]) -> bool:
145
+ """Whether a lab can supply every device kind a prompt asked for."""
146
+ for kind, needed in required.items():
147
+ if needed <= 0:
148
+ continue
149
+ names = TYPE_EQUIVALENTS.get(kind, (kind,))
150
+ if sum(counts.get(name, 0) for name in names) <= 0:
151
+ return False
152
+ return True
153
+
154
+
155
+ def local_donor_search_roots() -> list[Path]:
156
+ """Where to look for the user's own labs.
157
+
158
+ `PKT_LOCAL_DONOR_ROOTS` overrides, using the platform path separator.
159
+ """
160
+ override = (os.getenv("PKT_LOCAL_DONOR_ROOTS") or "").strip()
161
+ if override:
162
+ return [Path(part) for part in override.split(os.pathsep) if part.strip()]
163
+
164
+ roots = list(DEFAULT_SEARCH_ROOTS)
165
+
166
+ # A donor that ships with the skill. Everything else here depends on the
167
+ # user having Packet Tracer labs on disk -- their own, or the ones Packet
168
+ # Tracer installs -- so a machine with only the skill on it could not
169
+ # generate at all. This one carries a switch with hosts plus the device
170
+ # kinds no ordinary lab contains, and was built and saved through Packet
171
+ # Tracer itself, so it opens like any other.
172
+ shipped = Path(__file__).resolve().parent.parent / "templates" / "pt900" / "donors"
173
+ if shipped.exists():
174
+ roots.append(shipped)
175
+ # Packet Tracer installs several hundred labs under its own `saves/`, and
176
+ # measurement showed it opens every one of them: the version gate is an
177
+ # ordering on major.minor.patch, and a bundled sample is by definition at or
178
+ # below the installed release. They are searched last, so a user's own labs
179
+ # still win, but their presence is what lets a fresh machine generate
180
+ # without downloading a donor first.
181
+ try:
182
+ from packet_tracer_env import get_packet_tracer_saves_root
183
+
184
+ bundled = get_packet_tracer_saves_root()
185
+ except Exception: # noqa: BLE001 - donor discovery must never fail a run
186
+ bundled = None
187
+ if bundled is not None and bundled not in roots:
188
+ roots.append(bundled)
189
+ return roots
190
+
191
+
192
+ def local_donor_indexing_enabled() -> bool:
193
+ return (os.getenv("PKT_LOCAL_DONORS") or "").strip().lower() not in {"0", "off", "false", "no"}
194
+
195
+
196
+ def _version_of(path: Path) -> str:
197
+ """Read `<VERSION>` from the header alone, without decoding the whole lab."""
198
+ try:
199
+ header = peek_pkt_header(path.read_bytes())
200
+ except Exception: # noqa: BLE001 - an unreadable lab is simply not a donor
201
+ return ""
202
+ match = _VERSION_PATTERN.search(header)
203
+ return match.group(1).decode("utf-8", "replace").strip() if match else ""
204
+
205
+
206
+ def _load_index(index_path: Path) -> dict[str, dict[str, object]]:
207
+ try:
208
+ raw = json.loads(index_path.read_text(encoding="utf-8"))
209
+ except (OSError, ValueError):
210
+ return {}
211
+ entries = raw.get("entries") if isinstance(raw, dict) else None
212
+ return entries if isinstance(entries, dict) else {}
213
+
214
+
215
+ def _save_index(index_path: Path, entries: dict[str, dict[str, object]]) -> None:
216
+ try:
217
+ index_path.parent.mkdir(parents=True, exist_ok=True)
218
+ index_path.write_text(
219
+ json.dumps({"version": 1, "entries": entries}, indent=1), encoding="utf-8"
220
+ )
221
+ except OSError:
222
+ # The index is an optimisation. Failing to persist it must never fail a
223
+ # generation run.
224
+ pass
225
+
226
+
227
+ def discover_local_donors(
228
+ *,
229
+ roots: list[Path] | None = None,
230
+ index_path: Path | None = None,
231
+ scan_limit: int = DEFAULT_SCAN_LIMIT,
232
+ max_depth: int = DEFAULT_MAX_DEPTH,
233
+ exclude: set[str] | None = None,
234
+ required_types: dict[str, int] | None = None,
235
+ stop_after: int = 0,
236
+ ) -> list[LocalDonor]:
237
+ """Local labs whose build the running Packet Tracer will accept.
238
+
239
+ Results are cached by size and mtime, so only new or edited files pay the
240
+ header read.
241
+
242
+ `required_types` additionally filters on what a lab *contains*, which costs
243
+ a full decode the first time a lab is seen (~770 ms) and nothing after that.
244
+ Pass `stop_after` to return as soon as enough matches are found, so the
245
+ common case never pays for the whole collection.
246
+ """
247
+ if not local_donor_indexing_enabled():
248
+ return []
249
+
250
+ index_path = index_path or DEFAULT_INDEX_PATH
251
+ entries = _load_index(index_path)
252
+ target = get_packet_tracer_target_version()
253
+ policy = get_donor_policy()
254
+ excluded = {name.lower() for name in (exclude or set())}
255
+
256
+ found: list[LocalDonor] = []
257
+ scanned = 0
258
+ dirty = False
259
+ for root in roots or local_donor_search_roots():
260
+ if not root.exists():
261
+ continue
262
+ candidates = _iter_pkt_files(root, max_depth)
263
+ for path in candidates:
264
+ if scanned >= scan_limit:
265
+ break
266
+ scanned += 1
267
+ if path.name.lower() in excluded:
268
+ continue
269
+ try:
270
+ stat = path.stat()
271
+ except OSError:
272
+ continue
273
+ key = str(path)
274
+ cached = entries.get(key)
275
+ if (
276
+ isinstance(cached, dict)
277
+ and cached.get("size") == stat.st_size
278
+ and cached.get("mtime_ns") == stat.st_mtime_ns
279
+ ):
280
+ version = str(cached.get("version") or "")
281
+ else:
282
+ version = _version_of(path)
283
+ entries[key] = {
284
+ "size": stat.st_size,
285
+ "mtime_ns": stat.st_mtime_ns,
286
+ "version": version,
287
+ }
288
+ dirty = True
289
+ if not version:
290
+ continue
291
+ if not donor_tier_is_accepted(donor_compatibility(version, target), policy):
292
+ continue
293
+
294
+ counts: dict[str, int] | None = None
295
+ if required_types:
296
+ entry = entries.get(key) or {}
297
+ cached_counts = entry.get("device_counts")
298
+ # An entry cached before `__serial_routers__` existed carries
299
+ # every other count and would answer "no serial" for a lab full
300
+ # of it. The sentinel doubles as the cache's version marker:
301
+ # miss it, and the lab is decoded again once.
302
+ if isinstance(cached_counts, dict) and "__serial_routers__" not in cached_counts:
303
+ cached_counts = None
304
+ if isinstance(cached_counts, dict):
305
+ counts = {str(k): int(v) for k, v in cached_counts.items()}
306
+ else:
307
+ counts = _device_counts_of(path)
308
+ entry["device_counts"] = counts
309
+ entries[key] = entry
310
+ dirty = True
311
+ if not covers_requested_types(counts, required_types):
312
+ continue
313
+
314
+ found.append(LocalDonor(path=path, version=version, device_counts=counts))
315
+ if stop_after and len(found) >= stop_after:
316
+ if dirty:
317
+ _save_index(index_path, entries)
318
+ return found
319
+
320
+ if dirty:
321
+ _save_index(index_path, entries)
322
+ return found
323
+
324
+
325
+ def main() -> int: # pragma: no cover - operator convenience
326
+ import argparse
327
+
328
+ parser = argparse.ArgumentParser(description=__doc__)
329
+ parser.add_argument("--limit", type=int, default=DEFAULT_SCAN_LIMIT)
330
+ args = parser.parse_args()
331
+
332
+ donors = discover_local_donors(scan_limit=args.limit)
333
+ print(f"{len(donors)} local lab(s) usable as a generation base:")
334
+ for donor in donors[:40]:
335
+ print(f" {donor.version} {donor.path}")
336
+ return 0
337
+
338
+
339
+ if __name__ == "__main__": # pragma: no cover
340
+ raise SystemExit(main())