@0dai-dev/cli 4.3.6 → 4.3.8
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/README.md +12 -11
- package/bin/0dai.js +133 -33
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +707 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +298 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/play.js +20 -4
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +176 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +1 -1
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +943 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +96 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- package/scripts/postinstall.js +15 -9
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Config drift detection between agent configs.
|
|
3
|
+
|
|
4
|
+
Detects when native configs (CLAUDE.md, AGENTS.md, etc.) diverge from
|
|
5
|
+
what 0dai generated. Tracks hashes, finds contradictions.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import pathlib
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from json_utils import load_json, save_json
|
|
16
|
+
|
|
17
|
+
# Configs that 0dai generates
|
|
18
|
+
TRACKED_CONFIGS = [
|
|
19
|
+
"CLAUDE.md", "AGENTS.md", "GEMINI.md",
|
|
20
|
+
"opencode.json", ".cursorrules", ".windsurfrules",
|
|
21
|
+
".aider.conf.yml",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
HASHES_FILE = "ai/manifest/config_hashes.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Hash tracking
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _hashes_path(target: pathlib.Path) -> pathlib.Path:
|
|
32
|
+
return target / HASHES_FILE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compute_file_hash(file_path: pathlib.Path) -> str:
|
|
36
|
+
if not file_path.is_file():
|
|
37
|
+
return ""
|
|
38
|
+
return hashlib.sha256(file_path.read_bytes()).hexdigest()[:16]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_hashes(target: pathlib.Path) -> dict:
|
|
42
|
+
return load_json(_hashes_path(target))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_hashes(target: pathlib.Path, hashes: dict) -> None:
|
|
46
|
+
save_json(_hashes_path(target), hashes)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def record_hash(target: pathlib.Path, config_name: str) -> None:
|
|
50
|
+
"""Record hash for a generated config file."""
|
|
51
|
+
file_path = target / config_name
|
|
52
|
+
hashes = load_hashes(target)
|
|
53
|
+
hashes[config_name] = {
|
|
54
|
+
"hash": compute_file_hash(file_path),
|
|
55
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
56
|
+
}
|
|
57
|
+
save_hashes(target, hashes)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def record_all_hashes(target: pathlib.Path) -> int:
|
|
61
|
+
"""Record hashes for all existing tracked configs. Returns count."""
|
|
62
|
+
count = 0
|
|
63
|
+
hashes = load_hashes(target)
|
|
64
|
+
for name in TRACKED_CONFIGS:
|
|
65
|
+
fp = target / name
|
|
66
|
+
if fp.is_file():
|
|
67
|
+
hashes[name] = {
|
|
68
|
+
"hash": compute_file_hash(fp),
|
|
69
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
70
|
+
}
|
|
71
|
+
count += 1
|
|
72
|
+
save_hashes(target, hashes)
|
|
73
|
+
return count
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Drift detection
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def detect_drift(target: pathlib.Path) -> list[dict]:
|
|
81
|
+
"""Detect drift across all tracked configs."""
|
|
82
|
+
hashes = load_hashes(target)
|
|
83
|
+
findings: list[dict] = []
|
|
84
|
+
|
|
85
|
+
for name in TRACKED_CONFIGS:
|
|
86
|
+
fp = target / name
|
|
87
|
+
recorded = hashes.get(name)
|
|
88
|
+
|
|
89
|
+
if recorded and not fp.is_file():
|
|
90
|
+
# Config was generated but now missing
|
|
91
|
+
findings.append({
|
|
92
|
+
"config_file": name,
|
|
93
|
+
"drift_type": "missing",
|
|
94
|
+
"severity": "warning",
|
|
95
|
+
"description": f"{name} was generated but has been deleted",
|
|
96
|
+
"source_hash": recorded.get("hash", ""),
|
|
97
|
+
"current_hash": "",
|
|
98
|
+
"last_synced_at": recorded.get("generated_at", ""),
|
|
99
|
+
})
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if not recorded and fp.is_file():
|
|
103
|
+
# Config exists but wasn't generated by 0dai
|
|
104
|
+
findings.append({
|
|
105
|
+
"config_file": name,
|
|
106
|
+
"drift_type": "extra",
|
|
107
|
+
"severity": "info",
|
|
108
|
+
"description": f"{name} exists but wasn't generated by 0dai",
|
|
109
|
+
"source_hash": "",
|
|
110
|
+
"current_hash": compute_file_hash(fp),
|
|
111
|
+
"last_synced_at": "",
|
|
112
|
+
})
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if recorded and fp.is_file():
|
|
116
|
+
current = compute_file_hash(fp)
|
|
117
|
+
if current != recorded.get("hash", ""):
|
|
118
|
+
findings.append({
|
|
119
|
+
"config_file": name,
|
|
120
|
+
"drift_type": "modified",
|
|
121
|
+
"severity": "warning",
|
|
122
|
+
"description": f"{name} was manually edited after last sync",
|
|
123
|
+
"source_hash": recorded.get("hash", ""),
|
|
124
|
+
"current_hash": current,
|
|
125
|
+
"last_synced_at": recorded.get("generated_at", ""),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
# Check for contradictions between configs
|
|
129
|
+
findings.extend(detect_contradictions(target))
|
|
130
|
+
|
|
131
|
+
return findings
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Contradiction detection
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
# Keywords to look for in config content — grouped by topic
|
|
139
|
+
_TOPIC_PATTERNS: dict[str, list[tuple[str, re.Pattern]]] = {
|
|
140
|
+
"testing": [
|
|
141
|
+
("jest", re.compile(r"\bjest\b", re.IGNORECASE)),
|
|
142
|
+
("vitest", re.compile(r"\bvitest\b", re.IGNORECASE)),
|
|
143
|
+
("mocha", re.compile(r"\bmocha\b", re.IGNORECASE)),
|
|
144
|
+
("pytest", re.compile(r"\bpytest\b", re.IGNORECASE)),
|
|
145
|
+
("unittest", re.compile(r"\bunittest\b", re.IGNORECASE)),
|
|
146
|
+
],
|
|
147
|
+
"package_manager": [
|
|
148
|
+
("npm", re.compile(r"\bnpm\s+(install|run|ci|test)\b", re.IGNORECASE)),
|
|
149
|
+
("yarn", re.compile(r"\byarn\s+(add|run|install|test)\b", re.IGNORECASE)),
|
|
150
|
+
("pnpm", re.compile(r"\bpnpm\s+(add|run|install|test)\b", re.IGNORECASE)),
|
|
151
|
+
("bun", re.compile(r"\bbun\s+(add|run|install|test)\b", re.IGNORECASE)),
|
|
152
|
+
],
|
|
153
|
+
"node_version": [
|
|
154
|
+
("node18", re.compile(r"node\s*(?:version\s*)?:?\s*['\"]?18\b")),
|
|
155
|
+
("node20", re.compile(r"node\s*(?:version\s*)?:?\s*['\"]?20\b")),
|
|
156
|
+
("node22", re.compile(r"node\s*(?:version\s*)?:?\s*['\"]?22\b")),
|
|
157
|
+
],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def detect_contradictions(target: pathlib.Path) -> list[dict]:
|
|
162
|
+
"""Find contradicting values across config files."""
|
|
163
|
+
# Read all config contents
|
|
164
|
+
configs: dict[str, str] = {}
|
|
165
|
+
for name in TRACKED_CONFIGS:
|
|
166
|
+
fp = target / name
|
|
167
|
+
if fp.is_file():
|
|
168
|
+
try:
|
|
169
|
+
configs[name] = fp.read_text("utf-8", errors="ignore")
|
|
170
|
+
except OSError:
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
if len(configs) < 2:
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
findings: list[dict] = []
|
|
177
|
+
|
|
178
|
+
for topic, patterns in _TOPIC_PATTERNS.items():
|
|
179
|
+
# For each config, find which values are mentioned
|
|
180
|
+
config_values: dict[str, set[str]] = {}
|
|
181
|
+
for name, content in configs.items():
|
|
182
|
+
vals = set()
|
|
183
|
+
for val_name, regex in patterns:
|
|
184
|
+
if regex.search(content):
|
|
185
|
+
vals.add(val_name)
|
|
186
|
+
if vals:
|
|
187
|
+
config_values[name] = vals
|
|
188
|
+
|
|
189
|
+
if len(config_values) < 2:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# Check for contradictions: different configs mention different values
|
|
193
|
+
all_values = set()
|
|
194
|
+
for vals in config_values.values():
|
|
195
|
+
all_values.update(vals)
|
|
196
|
+
|
|
197
|
+
if len(all_values) <= 1:
|
|
198
|
+
continue # all agree
|
|
199
|
+
|
|
200
|
+
# Find specific contradictions
|
|
201
|
+
files_by_value: dict[str, list[str]] = {}
|
|
202
|
+
for name, vals in config_values.items():
|
|
203
|
+
for v in vals:
|
|
204
|
+
files_by_value.setdefault(v, []).append(name)
|
|
205
|
+
|
|
206
|
+
# Only flag if different files advocate different values
|
|
207
|
+
contradicting_pairs: list[tuple[str, str, str, str]] = []
|
|
208
|
+
seen = set()
|
|
209
|
+
for v1, files1 in files_by_value.items():
|
|
210
|
+
for v2, files2 in files_by_value.items():
|
|
211
|
+
if v1 >= v2:
|
|
212
|
+
continue
|
|
213
|
+
# Check if different files mention different values
|
|
214
|
+
f1_set = set(files1)
|
|
215
|
+
f2_set = set(files2)
|
|
216
|
+
if f1_set != f2_set and not f1_set.issubset(f2_set) and not f2_set.issubset(f1_set):
|
|
217
|
+
key = (v1, v2)
|
|
218
|
+
if key not in seen:
|
|
219
|
+
seen.add(key)
|
|
220
|
+
contradicting_pairs.append((v1, ", ".join(sorted(files1)), v2, ", ".join(sorted(files2))))
|
|
221
|
+
|
|
222
|
+
for v1, f1, v2, f2 in contradicting_pairs:
|
|
223
|
+
findings.append({
|
|
224
|
+
"config_file": f"{f1} vs {f2}",
|
|
225
|
+
"drift_type": "contradicts",
|
|
226
|
+
"severity": "critical",
|
|
227
|
+
"description": f"Contradiction in {topic}: {v1} ({f1}) vs {v2} ({f2})",
|
|
228
|
+
"source_hash": "",
|
|
229
|
+
"current_hash": "",
|
|
230
|
+
"last_synced_at": "",
|
|
231
|
+
"details": {
|
|
232
|
+
"topic": topic,
|
|
233
|
+
"value_a": v1,
|
|
234
|
+
"files_a": f1,
|
|
235
|
+
"value_b": v2,
|
|
236
|
+
"files_b": f2,
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
return findings
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# Drift summary
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def get_drift_summary(target: pathlib.Path) -> dict:
|
|
248
|
+
findings = detect_drift(target)
|
|
249
|
+
by_type = {}
|
|
250
|
+
for f in findings:
|
|
251
|
+
by_type[f["drift_type"]] = by_type.get(f["drift_type"], 0) + 1
|
|
252
|
+
total_tracked = sum(1 for n in TRACKED_CONFIGS if (target / n).is_file())
|
|
253
|
+
drifted = sum(1 for f in findings if f["drift_type"] in ("modified", "missing"))
|
|
254
|
+
return {
|
|
255
|
+
"total_configs": total_tracked,
|
|
256
|
+
"drifted": drifted,
|
|
257
|
+
"contradictions": by_type.get("contradicts", 0),
|
|
258
|
+
"findings": findings,
|
|
259
|
+
"clean": len(findings) == 0,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Accept drift (update hash to current)
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def accept_drift(target: pathlib.Path, config_name: str) -> dict:
|
|
268
|
+
fp = target / config_name
|
|
269
|
+
if not fp.is_file():
|
|
270
|
+
return {"ok": False, "error": f"{config_name} does not exist"}
|
|
271
|
+
hashes = load_hashes(target)
|
|
272
|
+
hashes[config_name] = {
|
|
273
|
+
"hash": compute_file_hash(fp),
|
|
274
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
275
|
+
"accepted": True,
|
|
276
|
+
}
|
|
277
|
+
save_hashes(target, hashes)
|
|
278
|
+
return {"ok": True, "config": config_name}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# CLI
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def cmd_drift_report(target: pathlib.Path) -> int:
|
|
286
|
+
summary = get_drift_summary(target)
|
|
287
|
+
if summary["clean"]:
|
|
288
|
+
print("Config drift: all configs match generated versions.")
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
print("Config Drift Report:\n")
|
|
292
|
+
for f in summary["findings"]:
|
|
293
|
+
if f["drift_type"] == "modified":
|
|
294
|
+
icon = "\U0001f7e1" # yellow circle
|
|
295
|
+
print(f" {icon} MODIFIED: {f['config_file']}")
|
|
296
|
+
print(f" Changed after last sync ({f.get('last_synced_at', '?')})")
|
|
297
|
+
print(" -> Run: 0dai sync --force to regenerate")
|
|
298
|
+
print(f" -> Or: 0dai drift accept {f['config_file']}")
|
|
299
|
+
elif f["drift_type"] == "missing":
|
|
300
|
+
print(f" \u274c MISSING: {f['config_file']}")
|
|
301
|
+
print(" -> Run: 0dai sync --force to regenerate")
|
|
302
|
+
elif f["drift_type"] == "extra":
|
|
303
|
+
print(f" \u2139 EXTRA: {f['config_file']}")
|
|
304
|
+
print(f" Not generated by 0dai. Run: 0dai drift accept {f['config_file']}")
|
|
305
|
+
elif f["drift_type"] == "contradicts":
|
|
306
|
+
print(f" \U0001f534 CONTRADICTS: {f['description']}")
|
|
307
|
+
print(" Agents will receive conflicting instructions")
|
|
308
|
+
print(" -> Run: 0dai sync --force to resolve")
|
|
309
|
+
print()
|
|
310
|
+
|
|
311
|
+
print(f"Drift score: {summary['drifted']}/{summary['total_configs']} configs drifted")
|
|
312
|
+
if summary["contradictions"]:
|
|
313
|
+
print(f"Contradictions: {summary['contradictions']}")
|
|
314
|
+
return 1 if summary["contradictions"] else 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cmd_accept(target: pathlib.Path, config_name: str) -> int:
|
|
318
|
+
result = accept_drift(target, config_name)
|
|
319
|
+
if result["ok"]:
|
|
320
|
+
print(f"Accepted {config_name} as new baseline. Drift cleared.")
|
|
321
|
+
return 0
|
|
322
|
+
print(result["error"])
|
|
323
|
+
return 1
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cmd_show(target: pathlib.Path, config_name: str) -> int:
|
|
327
|
+
hashes = load_hashes(target)
|
|
328
|
+
recorded = hashes.get(config_name)
|
|
329
|
+
fp = target / config_name
|
|
330
|
+
if not fp.is_file():
|
|
331
|
+
print(f"{config_name} does not exist.")
|
|
332
|
+
return 1
|
|
333
|
+
if not recorded:
|
|
334
|
+
print(f"{config_name} has no recorded baseline (not generated by 0dai).")
|
|
335
|
+
return 0
|
|
336
|
+
current = compute_file_hash(fp)
|
|
337
|
+
if current == recorded.get("hash", ""):
|
|
338
|
+
print(f"{config_name}: no drift (matches generated version).")
|
|
339
|
+
return 0
|
|
340
|
+
print(f"{config_name}: DRIFTED")
|
|
341
|
+
print(f" Generated: {recorded.get('generated_at', '?')}")
|
|
342
|
+
print(f" Hash: {recorded.get('hash', '?')} -> {current}")
|
|
343
|
+
print("\n Run: 0dai sync --force to regenerate")
|
|
344
|
+
print(f" Or: 0dai drift accept {config_name}")
|
|
345
|
+
return 1
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main() -> None:
|
|
349
|
+
target = pathlib.Path(".")
|
|
350
|
+
args = sys.argv[1:]
|
|
351
|
+
command = "report"
|
|
352
|
+
config_name = ""
|
|
353
|
+
|
|
354
|
+
i = 0
|
|
355
|
+
while i < len(args):
|
|
356
|
+
if args[i] == "--target" and i + 1 < len(args):
|
|
357
|
+
target = pathlib.Path(args[i + 1]).resolve()
|
|
358
|
+
i += 2
|
|
359
|
+
elif args[i] == "accept" and i + 1 < len(args):
|
|
360
|
+
command = "accept"
|
|
361
|
+
config_name = args[i + 1]
|
|
362
|
+
i += 2
|
|
363
|
+
elif args[i] == "show" and i + 1 < len(args):
|
|
364
|
+
command = "show"
|
|
365
|
+
config_name = args[i + 1]
|
|
366
|
+
i += 2
|
|
367
|
+
elif args[i] == "record":
|
|
368
|
+
command = "record"
|
|
369
|
+
i += 1
|
|
370
|
+
elif args[i] == "report":
|
|
371
|
+
command = "report"
|
|
372
|
+
i += 1
|
|
373
|
+
else:
|
|
374
|
+
i += 1
|
|
375
|
+
|
|
376
|
+
if command == "report":
|
|
377
|
+
sys.exit(cmd_drift_report(target))
|
|
378
|
+
elif command == "accept":
|
|
379
|
+
sys.exit(cmd_accept(target, config_name))
|
|
380
|
+
elif command == "show":
|
|
381
|
+
sys.exit(cmd_show(target, config_name))
|
|
382
|
+
elif command == "record":
|
|
383
|
+
count = record_all_hashes(target)
|
|
384
|
+
print(f"Recorded hashes for {count} configs.")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
if __name__ == "__main__":
|
|
388
|
+
main()
|