@softspark/ai-toolkit 1.6.1 → 1.8.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.
@@ -0,0 +1,537 @@
1
+ #!/usr/bin/env python3
2
+ """CLI dispatcher for ai-toolkit config subcommands.
3
+
4
+ Usage:
5
+ ai-toolkit config validate [path] — Validate .ai-toolkit.json
6
+ ai-toolkit config diff [path] — Show project vs base differences
7
+ ai-toolkit config init — Interactive config setup (MVP Phase 3)
8
+ ai-toolkit config create-base <name> — Scaffold base config package (MVP Phase 3)
9
+ ai-toolkit config check — CI enforcement check (MVP Phase 4)
10
+
11
+ Stdlib-only — no external dependencies.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ # Ensure scripts/ is on the path for sibling imports
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
21
+
22
+ from config_resolver import (
23
+ ConfigResolverError,
24
+ load_project_config,
25
+ resolve_extends,
26
+ )
27
+ from config_merger import ConfigMergeError, merge_config_chain
28
+ from config_validator import (
29
+ validate_merged_config,
30
+ validate_project_config,
31
+ )
32
+ from config_scaffold import create_base_package, create_project_config
33
+ from config_lock import check_lock_staleness
34
+
35
+
36
+ PROJECT_CONFIG_FILENAME = ".ai-toolkit.json"
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Subcommands
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def cmd_validate(args: list[str]) -> int:
44
+ """Validate .ai-toolkit.json schema and extends resolution."""
45
+ project_dir = Path(args[0]) if args else Path.cwd()
46
+
47
+ try:
48
+ config = load_project_config(project_dir)
49
+ except ConfigResolverError as e:
50
+ print(f" ✗ {e}")
51
+ return 1
52
+ if config is None:
53
+ print(f" ✗ No {PROJECT_CONFIG_FILENAME} found in {project_dir}")
54
+ return 2
55
+
56
+ print(f" Validating {project_dir / PROJECT_CONFIG_FILENAME}...")
57
+ errors = validate_project_config(config, project_dir)
58
+
59
+ if errors:
60
+ for e in errors:
61
+ print(f" ✗ {e}")
62
+ return 1
63
+
64
+ # If extends, try to resolve and validate merged config
65
+ extends = config.get("extends")
66
+ if extends:
67
+ print(f" Resolving extends: {extends}...")
68
+ try:
69
+ result = resolve_extends(extends, project_dir)
70
+ for w in result.warnings:
71
+ print(f" ⚠ {w}")
72
+
73
+ # Merge and validate
74
+ base_datas = [c.data for c in result.configs]
75
+ merge_result = merge_config_chain(base_datas, config)
76
+
77
+ # Post-merge enforcement validation
78
+ if base_datas:
79
+ merged_errors = validate_merged_config(
80
+ merge_result.merged, base_datas[-1]
81
+ )
82
+ if merged_errors:
83
+ for e in merged_errors:
84
+ print(f" ✗ {e}")
85
+ return 1
86
+
87
+ print(f" ✓ extends resolved: {len(result.configs)} base config(s)")
88
+ for c in result.configs:
89
+ version_str = f" v{c.version}" if c.version else ""
90
+ print(f" - {c.name}{version_str}")
91
+
92
+ except ConfigResolverError as e:
93
+ print(f" ✗ Resolution failed: {e}")
94
+ return 1
95
+ except ConfigMergeError as e:
96
+ print(f" ✗ Merge failed: {e}")
97
+ return 1
98
+
99
+ checks = [
100
+ ("schema valid", True),
101
+ ("no forbidden overrides", True),
102
+ ("constitution articles intact", True),
103
+ ]
104
+
105
+ if extends:
106
+ checks.append(("extends resolved", True))
107
+
108
+ for label, ok in checks:
109
+ print(f" {'✓' if ok else '✗'} {label}")
110
+
111
+ print("\n Config valid ✓")
112
+ return 0
113
+
114
+
115
+ def cmd_diff(args: list[str]) -> int:
116
+ """Show differences between project config and base config."""
117
+ project_dir = Path(args[0]) if args else Path.cwd()
118
+
119
+ try:
120
+ config = load_project_config(project_dir)
121
+ except ConfigResolverError as e:
122
+ print(f" ✗ {e}")
123
+ return 1
124
+ if config is None:
125
+ print(f" ✗ No {PROJECT_CONFIG_FILENAME} found in {project_dir}")
126
+ return 2
127
+
128
+ extends = config.get("extends")
129
+ if not extends:
130
+ print(" No 'extends' field — nothing to diff against.")
131
+ return 0
132
+
133
+ try:
134
+ result = resolve_extends(extends, project_dir)
135
+ except ConfigResolverError as e:
136
+ print(f" ✗ Cannot resolve extends: {e}")
137
+ return 1
138
+
139
+ if not result.configs:
140
+ print(" No base configs resolved.")
141
+ return 0
142
+
143
+ base = result.configs[-1] # most immediate parent
144
+ base_data = base.data
145
+
146
+ print(f" Base: {base.name}" + (f"@{base.version}" if base.version else ""))
147
+ print()
148
+
149
+ # Profile
150
+ base_profile = base_data.get("profile", "standard")
151
+ proj_profile = config.get("profile", base_profile)
152
+ if proj_profile != base_profile:
153
+ print(f" Profile: {base_profile} (base) → {proj_profile} (project) ⚠ OVERRIDE")
154
+ else:
155
+ print(f" Profile: {proj_profile} (inherited)")
156
+
157
+ # Agents
158
+ _diff_agents(base_data.get("agents", {}), config.get("agents", {}), base_data)
159
+
160
+ # Rules
161
+ _diff_rules(base_data.get("rules", {}), config.get("rules", {}))
162
+
163
+ # Constitution
164
+ _diff_constitution(base_data.get("constitution", {}), config.get("constitution", {}))
165
+
166
+ # Overrides
167
+ _diff_overrides(config.get("overrides", {}))
168
+
169
+ return 0
170
+
171
+
172
+ def _diff_agents(
173
+ base: dict, project: dict, full_base: dict
174
+ ) -> None:
175
+ """Print agent diff."""
176
+ base_enabled = set(base.get("enabled", []))
177
+ proj_enabled = set(project.get("enabled", []))
178
+ proj_disabled = set(project.get("disabled", []))
179
+ required = set(full_base.get("enforce", {}).get("requiredAgents", []))
180
+
181
+ added = proj_enabled - base_enabled
182
+ removed = proj_disabled & base_enabled
183
+
184
+ if added or removed or required:
185
+ print()
186
+ print(" Agents:")
187
+ for a in sorted(added):
188
+ print(f" + {a} (project adds)")
189
+ for a in sorted(removed):
190
+ print(f" - {a} (project disables)")
191
+ for a in sorted(required):
192
+ print(f" = {a} (base requires, cannot disable)")
193
+ for a in sorted(base_enabled - removed - required):
194
+ if a not in added:
195
+ print(f" {a} (inherited)")
196
+
197
+
198
+ def _diff_rules(base: dict, project: dict) -> None:
199
+ """Print rules diff."""
200
+ base_inject = set(base.get("inject", []))
201
+ proj_inject = set(project.get("inject", []))
202
+ proj_remove = set(project.get("remove", []))
203
+
204
+ added = proj_inject - base_inject
205
+ removed = proj_remove
206
+
207
+ if added or removed:
208
+ print()
209
+ print(" Rules:")
210
+ for r in sorted(added):
211
+ print(f" + {r} (project adds)")
212
+ for r in sorted(removed):
213
+ print(f" - {r} (project removes)")
214
+ for r in sorted(base_inject - removed):
215
+ print(f" = {r} (inherited)")
216
+
217
+
218
+ def _diff_constitution(base: dict, project: dict) -> None:
219
+ """Print constitution diff."""
220
+ base_articles = {a["article"]: a for a in base.get("amendments", [])}
221
+ proj_articles = {a["article"]: a for a in project.get("amendments", [])}
222
+
223
+ if base_articles or proj_articles:
224
+ print()
225
+ print(" Constitution:")
226
+ print(" = Articles I-V (immutable)")
227
+ for num, art in sorted(base_articles.items()):
228
+ print(f" = Article {num}: {art['title']} (inherited from base)")
229
+ for num, art in sorted(proj_articles.items()):
230
+ if num not in base_articles:
231
+ print(f" + Article {num}: {art['title']} (project adds)")
232
+
233
+
234
+ def _diff_overrides(overrides: dict) -> None:
235
+ """Print override diff."""
236
+ if overrides:
237
+ print()
238
+ print(" Overrides:")
239
+ for key, ov in overrides.items():
240
+ action = ov.get("replacement", "custom")
241
+ justification = ov.get("justification", "")
242
+ print(f" {key}: {action.upper()} (justification: \"{justification}\")")
243
+
244
+
245
+ def cmd_init(args: list[str]) -> int:
246
+ """Interactive (or flag-driven) project config setup."""
247
+ project_dir = Path.cwd()
248
+ config_path = project_dir / PROJECT_CONFIG_FILENAME
249
+
250
+ if config_path.is_file() and "--force" not in args:
251
+ print(f" {PROJECT_CONFIG_FILENAME} already exists. Use --force to overwrite.")
252
+ return 1
253
+
254
+ extends = ""
255
+ profile = "standard"
256
+
257
+ # Parse flags for non-interactive mode
258
+ i = 0
259
+ while i < len(args):
260
+ arg = args[i]
261
+ if arg == "--extends":
262
+ i += 1
263
+ extends = args[i] if i < len(args) else ""
264
+ elif arg.startswith("--extends="):
265
+ extends = arg.split("=", 1)[1]
266
+ elif arg == "--profile":
267
+ i += 1
268
+ profile = args[i] if i < len(args) else "standard"
269
+ elif arg.startswith("--profile="):
270
+ profile = arg.split("=", 1)[1]
271
+ elif arg == "--no-extends":
272
+ extends = ""
273
+ elif arg == "--force":
274
+ pass # handled above
275
+ i += 1
276
+
277
+ # If no flags provided and stdin is a TTY, do interactive mode
278
+ if not extends and not any(a.startswith("--") for a in args):
279
+ if sys.stdin.isatty():
280
+ try:
281
+ answer = input(" Does your organization have a shared ai-toolkit config? [y/n] ").strip().lower()
282
+ if answer in ("y", "yes"):
283
+ extends = input(" npm package name, git URL, or local path: ").strip()
284
+ profile_input = input(" Which profile? [minimal/standard/strict] (default: standard) ").strip()
285
+ if profile_input in ("minimal", "standard", "strict"):
286
+ profile = profile_input
287
+ except (EOFError, KeyboardInterrupt):
288
+ print("\n Cancelled.")
289
+ return 1
290
+ else:
291
+ # Non-interactive, no flags: create minimal config
292
+ pass
293
+
294
+ # Validate extends if provided
295
+ if extends:
296
+ print(f" Validating extends: {extends}...")
297
+ try:
298
+ result = resolve_extends(extends, project_dir)
299
+ for c in result.configs:
300
+ version_str = f" v{c.version}" if c.version else ""
301
+ print(f" ✓ Resolved: {c.name}{version_str}")
302
+ except ConfigResolverError as e:
303
+ print(f" ✗ Cannot resolve: {e}")
304
+ return 1
305
+
306
+ # Write config
307
+ config_path = create_project_config(project_dir, extends=extends, profile=profile)
308
+ print(f" Created: {config_path.name}")
309
+
310
+ # Suggest next step
311
+ print()
312
+ print(" Next: ai-toolkit install --local")
313
+
314
+ return 0
315
+
316
+
317
+ def cmd_create_base(args: list[str]) -> int:
318
+ """Scaffold a base config npm package."""
319
+ if not args or args[0].startswith("-"):
320
+ print("Usage: ai-toolkit config create-base <package-name> [output-dir]")
321
+ print()
322
+ print("Examples:")
323
+ print(" ai-toolkit config create-base @mycompany/ai-toolkit-config")
324
+ print(" ai-toolkit config create-base @mycompany/ai-toolkit-config ./packages")
325
+ return 1
326
+
327
+ name = args[0]
328
+ output_dir = Path(args[1]) if len(args) > 1 else None
329
+
330
+ print(f" Scaffolding base config: {name}...")
331
+ pkg_dir = create_base_package(name, output_dir)
332
+
333
+ files = sorted(p.relative_to(pkg_dir) for p in pkg_dir.rglob("*") if p.is_file())
334
+ print(f" Created: {pkg_dir}")
335
+ for f in files:
336
+ print(f" {f}")
337
+
338
+ print()
339
+ print(" Next steps:")
340
+ print(f" 1. cd {pkg_dir.name}")
341
+ print(" 2. Edit ai-toolkit.config.json — add your org's rules and agents")
342
+ print(" 3. npm publish")
343
+
344
+ return 0
345
+
346
+
347
+ def cmd_check(args: list[str]) -> int:
348
+ """CI enforcement check — verify project adheres to base config.
349
+
350
+ Exit codes:
351
+ 0 — project complies with base config
352
+ 1 — violations found
353
+ 2 — .ai-toolkit.json not found
354
+ """
355
+ project_dir = Path(args[0]) if args and not args[0].startswith("-") else Path.cwd()
356
+ json_output = "--json" in args
357
+
358
+ try:
359
+ config = load_project_config(project_dir)
360
+ except ConfigResolverError as e:
361
+ if json_output:
362
+ print(json.dumps({"status": "error", "code": 1, "message": str(e)}))
363
+ else:
364
+ print(f" ✗ {e}")
365
+ return 1
366
+ if config is None:
367
+ if json_output:
368
+ print(json.dumps({"status": "error", "code": 2, "message": "No .ai-toolkit.json found"}))
369
+ else:
370
+ print(f" ✗ No {PROJECT_CONFIG_FILENAME} found in {project_dir}")
371
+ return 2
372
+
373
+ checks: list[dict] = []
374
+ all_pass = True
375
+
376
+ # 1. Schema validation
377
+ errors = validate_project_config(config, project_dir)
378
+ checks.append({
379
+ "name": "schema_valid",
380
+ "label": "Schema valid",
381
+ "pass": len(errors) == 0,
382
+ "errors": errors,
383
+ })
384
+ if errors:
385
+ all_pass = False
386
+
387
+ # 2. Extends resolution + merge
388
+ extends = config.get("extends")
389
+ base_data: dict = {}
390
+ if extends:
391
+ try:
392
+ result = resolve_extends(extends, project_dir)
393
+ base_datas = [c.data for c in result.configs]
394
+ if base_datas:
395
+ base_data = base_datas[-1]
396
+ merge_result = merge_config_chain(base_datas, config)
397
+
398
+ checks.append({
399
+ "name": "extends_resolved",
400
+ "label": f"Extends resolved ({len(result.configs)} base config(s))",
401
+ "pass": True,
402
+ "errors": [],
403
+ })
404
+
405
+ # 3. Post-merge enforcement
406
+ merged_errors = validate_merged_config(merge_result.merged, base_data)
407
+ checks.append({
408
+ "name": "enforce_constraints",
409
+ "label": "Enforce constraints met",
410
+ "pass": len(merged_errors) == 0,
411
+ "errors": merged_errors,
412
+ })
413
+ if merged_errors:
414
+ all_pass = False
415
+
416
+ except ConfigResolverError as e:
417
+ checks.append({
418
+ "name": "extends_resolved",
419
+ "label": "Extends resolved",
420
+ "pass": False,
421
+ "errors": [str(e)],
422
+ })
423
+ all_pass = False
424
+ except ConfigMergeError as e:
425
+ checks.append({
426
+ "name": "merge_valid",
427
+ "label": "Config merge valid",
428
+ "pass": False,
429
+ "errors": [str(e)],
430
+ })
431
+ all_pass = False
432
+
433
+ # 4. Required agents
434
+ enforce = base_data.get("enforce", {})
435
+ required_agents = set(enforce.get("requiredAgents", []))
436
+ if required_agents:
437
+ disabled = set(config.get("agents", {}).get("disabled", []))
438
+ blocked = required_agents & disabled
439
+ checks.append({
440
+ "name": "required_agents",
441
+ "label": f"Required agents enabled ({', '.join(sorted(required_agents))})",
442
+ "pass": len(blocked) == 0,
443
+ "errors": [f"Cannot disable required agent: {a}" for a in sorted(blocked)],
444
+ })
445
+ if blocked:
446
+ all_pass = False
447
+
448
+ # 5. Constitution integrity
449
+ base_amendments = {a["article"] for a in base_data.get("constitution", {}).get("amendments", [])}
450
+ proj_amendments = {a["article"] for a in config.get("constitution", {}).get("amendments", [])}
451
+ conflicts = base_amendments & proj_amendments
452
+ checks.append({
453
+ "name": "constitution_intact",
454
+ "label": "Constitution articles intact",
455
+ "pass": len(conflicts) == 0,
456
+ "errors": [f"Article {a} conflicts with base" for a in sorted(conflicts)],
457
+ })
458
+ if conflicts:
459
+ all_pass = False
460
+
461
+ # 6. Lock file staleness
462
+ lock_status = check_lock_staleness(project_dir)
463
+ if lock_status:
464
+ checks.append({
465
+ "name": "lock_file",
466
+ "label": "Lock file up-to-date",
467
+ "pass": lock_status == "ok",
468
+ "errors": [] if lock_status == "ok" else [lock_status],
469
+ })
470
+ if lock_status != "ok":
471
+ # Lock staleness is a warning, not a failure
472
+ pass
473
+
474
+ # Output
475
+ if json_output:
476
+ print(json.dumps({
477
+ "status": "pass" if all_pass else "fail",
478
+ "code": 0 if all_pass else 1,
479
+ "checks": checks,
480
+ }, indent=2))
481
+ else:
482
+ for check in checks:
483
+ symbol = "✓" if check["pass"] else "✗"
484
+ print(f" {symbol} {check['label']}")
485
+ for err in check.get("errors", []):
486
+ print(f" {err}")
487
+
488
+ print()
489
+ if all_pass:
490
+ print(" Governance check passed ✓")
491
+ else:
492
+ print(" Governance check FAILED ✗")
493
+
494
+ return 0 if all_pass else 1
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # Main dispatch
499
+ # ---------------------------------------------------------------------------
500
+
501
+ SUBCOMMANDS = {
502
+ "validate": cmd_validate,
503
+ "diff": cmd_diff,
504
+ "init": cmd_init,
505
+ "create-base": cmd_create_base,
506
+ "check": cmd_check,
507
+ }
508
+
509
+
510
+ def main() -> None:
511
+ """Dispatch config subcommand."""
512
+ if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
513
+ print("Usage: ai-toolkit config <subcommand> [args...]")
514
+ print()
515
+ print("Subcommands:")
516
+ print(" validate [path] Validate .ai-toolkit.json schema + extends")
517
+ print(" diff [path] Show project vs base config differences")
518
+ print(" init [flags] Create .ai-toolkit.json (--extends, --profile, --no-extends)")
519
+ print(" create-base <name> Scaffold base config npm package")
520
+ print(" check [path] CI enforcement check (exit 0=pass, 1=fail, 2=no config)")
521
+ sys.exit(0)
522
+
523
+ subcmd = sys.argv[1]
524
+ args = sys.argv[2:]
525
+
526
+ handler = SUBCOMMANDS.get(subcmd)
527
+ if not handler:
528
+ print(f"Unknown config subcommand: {subcmd}", file=sys.stderr)
529
+ print(f"Valid: {', '.join(sorted(SUBCOMMANDS))}", file=sys.stderr)
530
+ sys.exit(1)
531
+
532
+ exit_code = handler(args)
533
+ sys.exit(exit_code or 0)
534
+
535
+
536
+ if __name__ == "__main__":
537
+ main()
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Lock file management for ai-toolkit config inheritance.
3
+
4
+ Generates and consumes .ai-toolkit.lock.json for reproducible
5
+ extends resolution across team members and CI.
6
+
7
+ Stdlib-only — no external dependencies.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ LOCK_FILENAME = ".ai-toolkit.lock.json"
19
+ LOCK_VERSION = 1
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Public API
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def load_lock_file(project_dir: Path) -> dict[str, Any] | None:
27
+ """Load lock file from project directory.
28
+
29
+ Returns None if the file doesn't exist.
30
+ """
31
+ lock_path = project_dir / LOCK_FILENAME
32
+ if not lock_path.is_file():
33
+ return None
34
+ try:
35
+ with open(lock_path, encoding="utf-8") as f:
36
+ return json.load(f)
37
+ except (json.JSONDecodeError, OSError):
38
+ return None
39
+
40
+
41
+ def save_lock_file(
42
+ project_dir: Path,
43
+ resolved_configs: list[dict[str, Any]],
44
+ ai_toolkit_version: str = "",
45
+ ) -> Path:
46
+ """Save lock file after successful extends resolution.
47
+
48
+ Args:
49
+ project_dir: Project root directory.
50
+ resolved_configs: List of resolved config metadata dicts
51
+ (each with source, name, version, integrity, root).
52
+ ai_toolkit_version: Current ai-toolkit version.
53
+
54
+ Returns:
55
+ Path to the created lock file.
56
+ """
57
+ lock_data: dict[str, Any] = {
58
+ "lockfileVersion": LOCK_VERSION,
59
+ "resolved": {},
60
+ "generated_at": _now_iso(),
61
+ "ai_toolkit_version": ai_toolkit_version,
62
+ }
63
+
64
+ for config in resolved_configs:
65
+ name = config.get("name", config.get("source", "unknown"))
66
+ lock_data["resolved"][name] = {
67
+ "version": config.get("version", ""),
68
+ "source": config.get("source", ""),
69
+ "integrity": config.get("integrity", ""),
70
+ "cached": config.get("root", ""),
71
+ }
72
+
73
+ lock_path = project_dir / LOCK_FILENAME
74
+ with open(lock_path, "w", encoding="utf-8") as f:
75
+ json.dump(lock_data, f, indent=2)
76
+ f.write("\n")
77
+
78
+ return lock_path
79
+
80
+
81
+ def check_lock_staleness(project_dir: Path) -> str:
82
+ """Check if lock file exists and is up-to-date.
83
+
84
+ Returns:
85
+ - "ok" if lock file exists and is current
86
+ - "missing" if no lock file
87
+ - "stale: <reason>" if lock file is outdated
88
+ - "" if no extends in config (lock not applicable)
89
+ """
90
+ from config_resolver import load_project_config
91
+
92
+ config = load_project_config(project_dir)
93
+ if config is None or not config.get("extends"):
94
+ return "" # No extends, lock file not applicable
95
+
96
+ lock = load_lock_file(project_dir)
97
+ if lock is None:
98
+ return "missing"
99
+
100
+ # Check lock version
101
+ if lock.get("lockfileVersion") != LOCK_VERSION:
102
+ return f"stale: lock version {lock.get('lockfileVersion')} != {LOCK_VERSION}"
103
+
104
+ # Check if resolved entries exist
105
+ resolved = lock.get("resolved", {})
106
+ if not resolved:
107
+ return "stale: no resolved entries"
108
+
109
+ return "ok"
110
+
111
+
112
+ def get_locked_version(project_dir: Path, config_name: str) -> str | None:
113
+ """Get the locked version for a specific config.
114
+
115
+ Returns None if not locked.
116
+ """
117
+ lock = load_lock_file(project_dir)
118
+ if lock is None:
119
+ return None
120
+ resolved = lock.get("resolved", {})
121
+ entry = resolved.get(config_name, {})
122
+ return entry.get("version") or None
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Helpers
127
+ # ---------------------------------------------------------------------------
128
+
129
+ def _now_iso() -> str:
130
+ """Return current UTC time in ISO 8601 format."""
131
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # CLI entry point (for testing)
136
+ # ---------------------------------------------------------------------------
137
+
138
+ def main() -> None:
139
+ """CLI: inspect lock file."""
140
+ if len(sys.argv) < 2:
141
+ print("Usage: config_lock.py <project-dir>", file=sys.stderr)
142
+ sys.exit(1)
143
+
144
+ project_dir = Path(sys.argv[1])
145
+ lock = load_lock_file(project_dir)
146
+
147
+ if lock is None:
148
+ print(json.dumps({"status": "no lock file"}))
149
+ else:
150
+ print(json.dumps(lock, indent=2))
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()