@softspark/ai-toolkit 1.7.0 → 1.9.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 +57 -5
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +6 -0
- package/bin/ai-toolkit.js +37 -0
- package/kb/{planning/enterprise-config-inheritance-plan.md → history/completed/enterprise-config-inheritance-plan-20260412.md} +39 -37
- package/kb/reference/enterprise-config-guide.md +329 -0
- package/llms-full.txt +2518 -2181
- package/llms.txt +2 -1
- package/manifest.json +9 -1
- package/package.json +1 -1
- 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 +163 -1
- package/scripts/install_steps/ai_tools.py +101 -1
- package/scripts/install_steps/install_state.py +24 -0
- package/scripts/install_steps/project_registry.py +142 -0
- package/scripts/projects_cli.py +110 -0
- package/scripts/schemas/ai-toolkit-config.schema.json +163 -0
- package/scripts/update_projects.py +141 -0
package/llms.txt
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
- [Best Practices](kb/best-practices/README.md)
|
|
15
15
|
- [No Hardcoded Counts in Secondary Docs](kb/best-practices/no-hardcoded-counts.md)
|
|
16
|
+
- [Plan: Enterprise Config Inheritance — Multi-Repo Governance with `extends`](kb/history/completed/enterprise-config-inheritance-plan-20260412.md)
|
|
16
17
|
- [Plan: Offline-First SLM Profile — Lightweight Mode for Local Models](kb/history/completed/offline-slm-profile-plan-20260411.md)
|
|
17
18
|
- [How-To Guides](kb/howto/README.md)
|
|
18
19
|
- [Plan: Cloud Security Pack — Multi-Cloud Audit](kb/planning/cloud-security-pack-plan.md)
|
|
19
|
-
- [Plan: Enterprise Config Inheritance — Multi-Repo Governance with `extends`](kb/planning/enterprise-config-inheritance-plan.md)
|
|
20
20
|
- [Plan: Local Dashboard — `ai-toolkit ui`](kb/planning/local-dashboard-plan.md)
|
|
21
21
|
- [SOP: Claude Toolkit Maintenance](kb/procedures/maintenance-sop.md)
|
|
22
22
|
- [SOP: Release Preparation](kb/procedures/release-preparation-sop.md)
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
- [Claude Ecosystem Expansion Foundations](kb/reference/claude-ecosystem-expansion-foundations.md)
|
|
31
31
|
- [Plan: Competitive Features — ai-toolkit](kb/reference/competitive-features-implementation.md)
|
|
32
32
|
- [Distribution Model](kb/reference/distribution-model.md)
|
|
33
|
+
- [Enterprise Config Inheritance Guide](kb/reference/enterprise-config-guide.md)
|
|
33
34
|
- [Extension API Reference](kb/reference/extension-api.md)
|
|
34
35
|
- [Global Install Model](kb/reference/global-install-model.md)
|
|
35
36
|
- [Hierarchical Override Pattern](kb/reference/hierarchical-override-pattern.md)
|
package/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
2
|
+
"version": "1.9.0",
|
|
3
3
|
"components": {
|
|
4
4
|
"agents": {
|
|
5
5
|
"description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
|
|
@@ -156,5 +156,13 @@
|
|
|
156
156
|
"strict": ["core", "agents", "skills", "rules-common", "mcp-templates"],
|
|
157
157
|
"full": ["core", "agents", "skills", "rules-common", "mcp-templates"],
|
|
158
158
|
"offline-slm": ["core"]
|
|
159
|
+
},
|
|
160
|
+
"config_inheritance": {
|
|
161
|
+
"schema": "scripts/schemas/ai-toolkit-config.schema.json",
|
|
162
|
+
"project_config": ".ai-toolkit.json",
|
|
163
|
+
"base_config": "ai-toolkit.config.json",
|
|
164
|
+
"lock_file": ".ai-toolkit.lock.json",
|
|
165
|
+
"v1_fields": ["extends", "profile", "agents", "rules", "constitution", "enforce"],
|
|
166
|
+
"max_extends_depth": 5
|
|
159
167
|
}
|
|
160
168
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Professional-grade AI coding toolkit: 92 skills, 44 agents, multi-platform support (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo Code, Aider, Augment, Google Antigravity), machine-enforced safety constitution, persona presets, skill security auditor, expanded lifecycle hooks, 11 plugin packs, and benchmark tooling.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -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()
|