@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.
- package/CHANGELOG.md +43 -0
- package/README.md +17 -4
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +3 -0
- package/bin/ai-toolkit.js +15 -0
- package/kb/{planning/enterprise-config-inheritance-plan.md → history/completed/enterprise-config-inheritance-plan-20260412.md} +39 -37
- package/kb/{planning/offline-slm-profile-plan.md → history/completed/offline-slm-profile-plan-20260411.md} +7 -5
- package/kb/reference/architecture-overview.md +1 -0
- package/kb/reference/enterprise-config-guide.md +329 -0
- package/kb/reference/skills-catalog.md +15 -0
- package/llms-full.txt +3143 -2788
- package/llms.txt +3 -2
- package/manifest.json +13 -4
- package/package.json +1 -1
- package/scripts/compile_slm.py +1043 -0
- package/scripts/config_cli.py +537 -0
- package/scripts/config_lock.py +154 -0
- package/scripts/config_merger.py +455 -0
- package/scripts/config_resolver.py +507 -0
- package/scripts/config_scaffold.py +266 -0
- package/scripts/config_validator.py +389 -0
- package/scripts/install.py +149 -1
- package/scripts/install_steps/ai_tools.py +101 -1
- package/scripts/install_steps/install_state.py +24 -0
- package/scripts/schemas/ai-toolkit-config.schema.json +163 -0
|
@@ -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()
|