@softspark/ai-toolkit 1.7.0 → 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.
@@ -58,6 +58,16 @@ from install_steps.install_state import (
58
58
  )
59
59
  from install_steps.detect_language import detect_languages
60
60
 
61
+ # Config inheritance (extends system)
62
+ from config_resolver import (
63
+ ConfigResolverError,
64
+ load_project_config,
65
+ resolve_extends,
66
+ )
67
+ from config_merger import ConfigMergeError, merge_config_chain
68
+ from config_validator import validate_project_config
69
+ from config_lock import save_lock_file
70
+
61
71
 
62
72
  # ---------------------------------------------------------------------------
63
73
  # Manifest helpers
@@ -199,6 +209,8 @@ def parse_args(argv: list[str]) -> dict:
199
209
  "status": False,
200
210
  "lang": "",
201
211
  "editors": "",
212
+ "config": "",
213
+ "refresh_base": False,
202
214
  }
203
215
  i = 0
204
216
  while i < len(argv):
@@ -248,6 +260,13 @@ def parse_args(argv: list[str]) -> dict:
248
260
  elif arg == "--editors":
249
261
  i += 1
250
262
  cfg["editors"] = argv[i] if i < len(argv) else ""
263
+ elif arg.startswith("--config="):
264
+ cfg["config"] = arg.split("=", 1)[1]
265
+ elif arg == "--config":
266
+ i += 1
267
+ cfg["config"] = argv[i] if i < len(argv) else ""
268
+ elif arg == "--refresh-base":
269
+ cfg["refresh_base"] = True
251
270
  elif arg.startswith("-"):
252
271
  print(f"Unknown option: {arg}")
253
272
  sys.exit(1)
@@ -442,6 +461,117 @@ def install_strict_git_hooks(profile: str, local: bool, dry_run: bool) -> None:
442
461
  # Version helper
443
462
  # ---------------------------------------------------------------------------
444
463
 
464
+ def resolve_extends_config(
465
+ project_dir: Path,
466
+ config_path: str = "",
467
+ refresh: bool = False,
468
+ ) -> dict | None:
469
+ """Resolve .ai-toolkit.json extends and return merged config.
470
+
471
+ Returns None if no .ai-toolkit.json or no extends field.
472
+ Prints warnings/errors and exits on fatal errors.
473
+ """
474
+ if config_path:
475
+ config_file = Path(config_path)
476
+ if not config_file.is_file():
477
+ print(f" Error: config file not found: {config_path}")
478
+ sys.exit(1)
479
+ import json as _json
480
+ with open(config_file, encoding="utf-8") as f:
481
+ project_config = _json.load(f)
482
+ config_root = config_file.parent
483
+ else:
484
+ project_config = load_project_config(project_dir)
485
+ config_root = project_dir
486
+
487
+ if project_config is None:
488
+ return None
489
+
490
+ # Validate project config schema
491
+ errors = validate_project_config(project_config, config_root)
492
+ if errors:
493
+ print(" Config validation errors:")
494
+ for e in errors:
495
+ print(f" ✗ {e}")
496
+ sys.exit(1)
497
+
498
+ extends = project_config.get("extends")
499
+ if not extends:
500
+ # Config without extends — just use its settings directly
501
+ return project_config
502
+
503
+ print(f" Resolving extends: {extends}...")
504
+
505
+ try:
506
+ result = resolve_extends(extends, config_root, refresh=refresh)
507
+ except ConfigResolverError as e:
508
+ print(f" ✗ Resolution failed: {e}")
509
+ sys.exit(1)
510
+
511
+ for w in result.warnings:
512
+ print(f" ⚠ {w}")
513
+
514
+ # Merge
515
+ try:
516
+ base_datas = [c.data for c in result.configs]
517
+ merge_result = merge_config_chain(base_datas, project_config)
518
+ except ConfigMergeError as e:
519
+ print(f" ✗ Merge failed: {e}")
520
+ sys.exit(1)
521
+
522
+ for c in result.configs:
523
+ version_str = f" v{c.version}" if c.version else ""
524
+ print(f" ✓ Resolved: {c.name}{version_str}")
525
+
526
+ # Attach resolution metadata for state.json recording
527
+ config_metas = [
528
+ {
529
+ "source": c.source,
530
+ "name": c.name,
531
+ "version": c.version,
532
+ "integrity": c.integrity,
533
+ "root": str(c.root),
534
+ }
535
+ for c in result.configs
536
+ ]
537
+ merge_result.merged["_extends_meta"] = {
538
+ "source": extends,
539
+ "configs": config_metas,
540
+ "overrides_applied": merge_result.overrides_applied,
541
+ }
542
+
543
+ # Generate lock file
544
+ lock_path = save_lock_file(
545
+ config_root,
546
+ config_metas,
547
+ ai_toolkit_version=_get_toolkit_version(),
548
+ )
549
+ print(f" Saved: {lock_path.name}")
550
+
551
+ return merge_result.merged
552
+
553
+
554
+ def _apply_merged_config(
555
+ merged: dict,
556
+ cfg: dict,
557
+ ) -> dict:
558
+ """Apply merged config settings back into the install cfg dict.
559
+
560
+ Overrides profile and modules based on merged config.
561
+ Returns the modified cfg.
562
+ """
563
+ # Profile from merged config
564
+ merged_profile = merged.get("profile")
565
+ if merged_profile and not cfg["profile"]:
566
+ cfg["profile"] = merged_profile
567
+
568
+ # Agents: filter based on merged enabled/disabled lists
569
+ # (stored for use by install_local_project)
570
+ cfg["_merged_config"] = merged
571
+
572
+ return cfg
573
+
574
+
445
575
  def _get_toolkit_version() -> str:
446
576
  """Read version from package.json."""
447
577
  pkg = toolkit_dir / "package.json"
@@ -522,10 +652,21 @@ def main() -> None:
522
652
 
523
653
  if local:
524
654
  # --local: project-local only, no global install
655
+ # Check for .ai-toolkit.json extends system
656
+ config_path_arg: str = cfg["config"]
657
+ refresh_base: bool = cfg["refresh_base"]
658
+ merged_config = resolve_extends_config(
659
+ project_dir, config_path=config_path_arg, refresh=refresh_base,
660
+ )
661
+ if merged_config:
662
+ cfg = _apply_merged_config(merged_config, cfg)
663
+ profile = cfg["profile"]
664
+
525
665
  lang_modules = [m for m in (resolved_modules or []) if m.startswith("rules-")]
526
666
  editors_arg: str = cfg["editors"]
527
667
  install_local_project(rules_dir, dry_run, reset, lang_modules or None,
528
- editors=editors_arg)
668
+ editors=editors_arg,
669
+ merged_config=merged_config)
529
670
  install_strict_git_hooks(profile, local, dry_run)
530
671
  else:
531
672
  # Global install
@@ -549,11 +690,18 @@ def main() -> None:
549
690
  # Legacy mode: infer modules from profile/only
550
691
  record_modules = _infer_modules_from_legacy(profile, only)
551
692
 
693
+ # Extract extends metadata if available
694
+ extends_info = None
695
+ merged = cfg.get("_merged_config")
696
+ if merged and merged.get("_extends_meta"):
697
+ extends_info = merged["_extends_meta"]
698
+
552
699
  record_install(
553
700
  version=_get_toolkit_version(),
554
701
  modules=record_modules,
555
702
  profile=profile or "standard",
556
703
  auto_detected=auto_detected,
704
+ extends_info=extends_info,
557
705
  )
558
706
 
559
707
  print_summary(local=local)
@@ -172,7 +172,8 @@ def _resolve_editors(editors_arg: str, cwd: Path) -> list[str]:
172
172
 
173
173
  def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
174
174
  language_modules: list[str] | None = None,
175
- editors: str = "") -> None:
175
+ editors: str = "",
176
+ merged_config: dict | None = None) -> None:
176
177
  """Install project-local configs.
177
178
 
178
179
  Claude Code configs (CLAUDE.md, settings, constitution) are always installed.
@@ -180,12 +181,18 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
180
181
  - ``--editors all``: install all editors
181
182
  - ``--editors cursor,aider``: install only these
182
183
  - (empty): auto-detect from existing project files, install only those
184
+
185
+ If ``merged_config`` is provided (from .ai-toolkit.json extends resolution),
186
+ additional rules and constitution amendments from the base config are injected.
183
187
  """
184
188
  cwd = Path.cwd()
185
189
  resolved_editors = _resolve_editors(editors, cwd)
186
190
 
187
191
  print()
188
192
  print(f"## Project-local ({cwd})")
193
+ if merged_config and merged_config.get("_extends_meta"):
194
+ meta = merged_config["_extends_meta"]
195
+ print(f" Config: .ai-toolkit.json (extends: {meta['source']})")
189
196
  if reset:
190
197
  print(" Mode: RESET (all local configs will be wiped and recreated)")
191
198
  if resolved_editors:
@@ -198,6 +205,8 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
198
205
  _install_local_dry_run(reset, resolved_editors)
199
206
  if language_modules:
200
207
  print(f" Would inject language rules: {', '.join(language_modules)}")
208
+ if merged_config:
209
+ print(f" Would apply merged config from extends")
201
210
  return
202
211
 
203
212
  (cwd / ".claude").mkdir(parents=True, exist_ok=True)
@@ -223,6 +232,10 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
223
232
  )
224
233
  print(" Injected: .claude/constitution.md")
225
234
 
235
+ # Apply extends: inject base rules and constitution amendments
236
+ if merged_config:
237
+ _apply_extends_config(cwd, merged_config)
238
+
226
239
  # Inject language-specific rules into project CLAUDE.md
227
240
  _inject_language_rules(cwd, language_modules)
228
241
 
@@ -231,6 +244,93 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
231
244
  language_modules=language_modules)
232
245
 
233
246
 
247
+ def _apply_extends_config(cwd: Path, merged: dict) -> None:
248
+ """Apply merged extends config — inject base rules and constitution amendments."""
249
+ import json as _json
250
+
251
+ # Inject base rules into CLAUDE.md
252
+ rules = merged.get("rules", {})
253
+ inject_rules = rules.get("inject", [])
254
+ if inject_rules:
255
+ claude_md = cwd / ".claude" / "CLAUDE.md"
256
+ if claude_md.is_file():
257
+ content = claude_md.read_text(encoding="utf-8")
258
+ else:
259
+ content = ""
260
+
261
+ # Add extends rules section if not already present
262
+ marker_start = "<!-- TOOLKIT:extends-rules START -->"
263
+ marker_end = "<!-- TOOLKIT:extends-rules END -->"
264
+
265
+ rules_block = f"\n{marker_start}\n"
266
+ rules_block += "# Inherited Rules (from base config)\n\n"
267
+ for rule_path in inject_rules:
268
+ rules_block += f"- Rule: `{rule_path}`\n"
269
+ rules_block += f"{marker_end}\n"
270
+
271
+ if marker_start in content:
272
+ # Replace existing section
273
+ import re
274
+ content = re.sub(
275
+ f"{re.escape(marker_start)}.*?{re.escape(marker_end)}",
276
+ f"{marker_start}\n# Inherited Rules (from base config)\n\n"
277
+ + "".join(f"- Rule: `{r}`\n" for r in inject_rules)
278
+ + marker_end,
279
+ content,
280
+ flags=re.DOTALL,
281
+ )
282
+ else:
283
+ content += rules_block
284
+
285
+ claude_md.write_text(content, encoding="utf-8")
286
+ print(f" Injected: {len(inject_rules)} rule(s) from base config")
287
+
288
+ # Inject constitution amendments
289
+ amendments = merged.get("constitution", {}).get("amendments", [])
290
+ # Filter to non-toolkit articles (6+)
291
+ custom_amendments = [a for a in amendments if a.get("article", 0) >= 6]
292
+ if custom_amendments:
293
+ constitution_file = cwd / ".claude" / "constitution.md"
294
+ if constitution_file.is_file():
295
+ content = constitution_file.read_text(encoding="utf-8")
296
+ else:
297
+ content = ""
298
+
299
+ marker_start = "<!-- TOOLKIT:extends-constitution START -->"
300
+ marker_end = "<!-- TOOLKIT:extends-constitution END -->"
301
+
302
+ amendments_block = f"\n{marker_start}\n"
303
+ for a in custom_amendments:
304
+ amendments_block += f"\n## Article {a['article']}: {a['title']}\n\n"
305
+ amendments_block += f"{a['text']}\n"
306
+ amendments_block += f"\n{marker_end}\n"
307
+
308
+ if marker_start in content:
309
+ import re
310
+ new_inner = ""
311
+ for a in custom_amendments:
312
+ new_inner += f"\n## Article {a['article']}: {a['title']}\n\n"
313
+ new_inner += f"{a['text']}\n"
314
+ content = re.sub(
315
+ f"{re.escape(marker_start)}.*?{re.escape(marker_end)}",
316
+ f"{marker_start}{new_inner}\n{marker_end}",
317
+ content,
318
+ flags=re.DOTALL,
319
+ )
320
+ else:
321
+ content += amendments_block
322
+
323
+ constitution_file.write_text(content, encoding="utf-8")
324
+ print(f" Injected: {len(custom_amendments)} constitution amendment(s) from base config")
325
+
326
+ # Record merged config summary
327
+ meta = merged.get("_extends_meta")
328
+ if meta:
329
+ state_file = cwd / ".ai-toolkit-extends.json"
330
+ state_file.write_text(_json.dumps(meta, indent=2) + "\n", encoding="utf-8")
331
+ print(f" Saved: .ai-toolkit-extends.json (resolution metadata)")
332
+
333
+
234
334
  def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
235
335
  """Inject language-specific rule summary into project's .claude/CLAUDE.md.
236
336
 
@@ -68,11 +68,15 @@ def record_install(
68
68
  modules: list[str],
69
69
  profile: str,
70
70
  auto_detected: list[str] | None = None,
71
+ extends_info: dict | None = None,
71
72
  ) -> None:
72
73
  """Record a successful install in state.json.
73
74
 
74
75
  If state already exists, preserves ``installed_at`` and updates
75
76
  ``last_updated``. Otherwise sets both timestamps.
77
+
78
+ ``extends_info`` (optional) records config inheritance metadata:
79
+ source, version, resolved_at, hash, overrides_applied.
76
80
  """
77
81
  state = load_state()
78
82
  now = _now_iso()
@@ -86,6 +90,17 @@ def record_install(
86
90
  if auto_detected is not None:
87
91
  state["auto_detected_languages"] = sorted(auto_detected)
88
92
 
93
+ if extends_info is not None:
94
+ state["extends"] = {
95
+ "source": extends_info.get("source", ""),
96
+ "configs": extends_info.get("configs", []),
97
+ "resolved_at": now,
98
+ "overrides_applied": extends_info.get("overrides_applied", []),
99
+ }
100
+ elif "extends" in state:
101
+ # Clear extends if no longer using it
102
+ del state["extends"]
103
+
89
104
  save_state(state)
90
105
 
91
106
  # Clear version check cache (version may have changed)
@@ -121,6 +136,15 @@ def print_status() -> None:
121
136
  langs = [m.replace("rules-", "") for m in detected]
122
137
  print(f" Detected: {', '.join(langs)}")
123
138
 
139
+ extends = state.get("extends")
140
+ if extends:
141
+ print(f" Extends: {extends.get('source', 'unknown')}")
142
+ for cfg in extends.get("configs", []):
143
+ version_str = f" v{cfg['version']}" if cfg.get("version") else ""
144
+ print(f" → {cfg.get('name', cfg.get('source', '?'))}{version_str}")
145
+ if extends.get("resolved_at"):
146
+ print(f" Resolved: {extends['resolved_at']}")
147
+
124
148
  # Check for updates
125
149
  try:
126
150
  import sys as _sys
@@ -0,0 +1,163 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft-07/schema#",
3
+ "$id": "https://softspark.github.io/ai-toolkit/schemas/ai-toolkit-config.json",
4
+ "title": "ai-toolkit project configuration",
5
+ "description": "Project-level configuration for ai-toolkit with optional inheritance via extends.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string",
11
+ "description": "JSON Schema reference (ignored at runtime)."
12
+ },
13
+ "extends": {
14
+ "type": "string",
15
+ "description": "Base config to inherit from. npm package name (@scope/pkg or @scope/pkg@version), git URL (git+https://...), or local path (./path or ../path).",
16
+ "examples": [
17
+ "@mycompany/ai-toolkit-config",
18
+ "@mycompany/ai-toolkit-config@^2.0.0",
19
+ "git+https://github.com/myco/ai-config.git",
20
+ "../shared-config"
21
+ ]
22
+ },
23
+ "name": {
24
+ "type": "string",
25
+ "description": "Config identity (required for base configs published as npm packages)."
26
+ },
27
+ "version": {
28
+ "type": "string",
29
+ "description": "Semantic version (required for base configs).",
30
+ "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?(\\+[a-zA-Z0-9.]+)?$"
31
+ },
32
+ "description": {
33
+ "type": "string",
34
+ "description": "Human-readable description of this configuration."
35
+ },
36
+ "profile": {
37
+ "type": "string",
38
+ "enum": ["minimal", "standard", "strict", "full", "offline-slm"],
39
+ "default": "standard",
40
+ "description": "Installation profile controlling which modules are installed."
41
+ },
42
+ "agents": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "description": "Agent configuration — enable, disable, or add custom agents.",
46
+ "properties": {
47
+ "enabled": {
48
+ "type": "array",
49
+ "items": { "type": "string" },
50
+ "description": "Agent names to enable (merged with base)."
51
+ },
52
+ "disabled": {
53
+ "type": "array",
54
+ "items": { "type": "string" },
55
+ "description": "Agent names to disable (blocked if base enforces them)."
56
+ },
57
+ "custom": {
58
+ "type": "array",
59
+ "items": { "type": "string" },
60
+ "description": "Paths to custom agent .md files (relative to config root)."
61
+ }
62
+ }
63
+ },
64
+ "rules": {
65
+ "type": "object",
66
+ "additionalProperties": false,
67
+ "description": "Rule injection configuration.",
68
+ "properties": {
69
+ "inject": {
70
+ "type": "array",
71
+ "items": { "type": "string" },
72
+ "description": "Paths to rule .md files to inject into CLAUDE.md."
73
+ },
74
+ "remove": {
75
+ "type": "array",
76
+ "items": { "type": "string" },
77
+ "description": "Rule names to remove from base config."
78
+ }
79
+ }
80
+ },
81
+ "constitution": {
82
+ "type": "object",
83
+ "additionalProperties": false,
84
+ "description": "Constitution amendments. Articles I-V are immutable. Base articles are immutable. Projects can only ADD new articles.",
85
+ "properties": {
86
+ "amendments": {
87
+ "type": "array",
88
+ "items": {
89
+ "type": "object",
90
+ "required": ["article", "title", "text"],
91
+ "additionalProperties": false,
92
+ "properties": {
93
+ "article": {
94
+ "type": "integer",
95
+ "minimum": 1,
96
+ "description": "Article number. 1-5 are reserved (immutable). Base articles are also immutable."
97
+ },
98
+ "title": {
99
+ "type": "string",
100
+ "description": "Article title."
101
+ },
102
+ "text": {
103
+ "type": "string",
104
+ "description": "Article text."
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ },
111
+ "enforce": {
112
+ "type": "object",
113
+ "additionalProperties": false,
114
+ "description": "Non-overridable constraints (base configs only). Projects inheriting this config must comply.",
115
+ "properties": {
116
+ "minHookProfile": {
117
+ "type": "string",
118
+ "enum": ["minimal", "standard", "strict"],
119
+ "description": "Minimum hook profile — projects cannot go below this."
120
+ },
121
+ "requiredPlugins": {
122
+ "type": "array",
123
+ "items": { "type": "string" },
124
+ "description": "Plugins that must be installed in all inheriting projects."
125
+ },
126
+ "forbidOverride": {
127
+ "type": "array",
128
+ "items": { "type": "string" },
129
+ "description": "Components that cannot be overridden by inheriting projects."
130
+ },
131
+ "requiredAgents": {
132
+ "type": "array",
133
+ "items": { "type": "string" },
134
+ "description": "Agents that must be enabled in all inheriting projects."
135
+ }
136
+ }
137
+ },
138
+ "overrides": {
139
+ "type": "object",
140
+ "additionalProperties": {
141
+ "type": "object",
142
+ "required": ["override", "justification"],
143
+ "properties": {
144
+ "override": {
145
+ "type": "boolean",
146
+ "const": true,
147
+ "description": "Must be true to confirm intentional override."
148
+ },
149
+ "justification": {
150
+ "type": "string",
151
+ "minLength": 20,
152
+ "description": "Explanation for why this override is needed (min 20 chars)."
153
+ },
154
+ "replacement": {
155
+ "type": "string",
156
+ "description": "Replacement value or 'skip' to disable."
157
+ }
158
+ }
159
+ },
160
+ "description": "Explicit overrides of base config components. Each override requires override:true + justification."
161
+ }
162
+ }
163
+ }