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.
- package/CHANGELOG.md +424 -42
- package/README.md +535 -250
- package/SKILL.md +337 -262
- package/bin/packet-tracer-skill.js +29 -2
- package/docs/automation-controller-proof.md +35 -0
- package/docs/curated-donor-registry.md +11 -0
- package/docs/generate-ready-pilot-design.md +30 -0
- package/docs/github-launch-ops-0.2.3.md +37 -0
- package/docs/github-metadata.md +6 -4
- package/docs/hero-demo-plan.md +1 -1
- package/docs/home-iot-donor-proof.md +4 -4
- package/docs/industrial-programming-proof.md +48 -0
- package/docs/ipv4-routing-management-proof.md +37 -0
- package/docs/l2-resiliency-bgp-proof.md +60 -0
- package/docs/l2-security-qos-proof.md +59 -0
- package/docs/packet-tracer-feature-gap-atlas.md +174 -17
- package/docs/post-launch-follow-up.md +9 -5
- package/docs/proof-readiness-dashboard.md +69 -0
- package/docs/publish-preview-roadmap.md +6 -5
- package/docs/release-checklist.md +27 -13
- package/docs/release-notes-0.2.2.md +1 -1
- package/docs/release-notes-0.2.3.md +59 -0
- package/docs/release-notes-0.2.4.md +20 -0
- package/docs/runtime-truth.md +33 -8
- package/docs/security-edge-deepening-proof.md +65 -0
- package/docs/voice-collaboration-proof.md +38 -0
- package/docs/wan-security-donor-proof.md +20 -3
- package/examples/README.md +98 -69
- package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
- package/examples/gallery.md +94 -6
- package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
- package/examples/index.json +932 -4
- package/examples/local-sample-evidence.json +24 -0
- package/examples/proof-cards.json +117 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
- package/package.json +60 -44
- package/pytest.ini +9 -0
- package/references/packettracer-feature-atlas.json +67 -17
- package/references/packettracer-sample-catalog.json +45287 -4525
- package/references/packettracer-sample-catalog.md +599 -259
- package/references/proof-readiness-candidates.json +352 -0
- package/scripts/build_examples_index.py +228 -35
- package/scripts/build_sample_catalog.py +24 -44
- package/scripts/corpus_runner.py +430 -0
- package/scripts/coverage_matrix.py +1842 -1319
- package/scripts/donor_cache.py +354 -0
- package/scripts/donor_diagnostics.py +3 -1
- package/scripts/feature_atlas.py +65 -1
- package/scripts/generate_pkt.py +8762 -4070
- package/scripts/intent_parser.py +2242 -1138
- package/scripts/local_donors.py +340 -0
- package/scripts/packet_tracer_env.py +846 -391
- package/scripts/pkt_annotate.py +218 -0
- package/scripts/pkt_codec.py +420 -181
- package/scripts/pkt_editor.py +2405 -1226
- package/scripts/pkt_transformer.py +1072 -727
- package/scripts/pkt_verify.py +461 -0
- package/scripts/remote_search.py +197 -21
- package/scripts/runtime_doctor.py +80 -29
- package/scripts/sample_catalog.py +1372 -1195
- package/scripts/twofish_diagnostics.py +48 -31
- package/scripts/usage_ledger.py +218 -0
- package/scripts/vendor/README.md +44 -37
- package/scripts/vendor/twofish_pure.py +321 -0
- package/scripts/workspace_repair.py +548 -508
- 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())
|