@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/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.7.0",
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.7.0",
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()