@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.
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env python3
2
+ """Scaffolding for ai-toolkit config inheritance.
3
+
4
+ - create_base_package(): Scaffold an npm base config package
5
+ - create_project_config(): Generate .ai-toolkit.json for a project
6
+
7
+ Stdlib-only — no external dependencies.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Base config scaffolder
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def create_base_package(
22
+ name: str,
23
+ output_dir: Path | None = None,
24
+ ) -> Path:
25
+ """Scaffold a base config npm package.
26
+
27
+ Args:
28
+ name: Package name (e.g., '@mycompany/ai-toolkit-config').
29
+ output_dir: Where to create the package directory.
30
+ Defaults to CWD / sanitized-name.
31
+
32
+ Returns:
33
+ Path to the created package directory.
34
+ """
35
+ # Sanitize directory name
36
+ dir_name = name.replace("@", "").replace("/", "-")
37
+ pkg_dir = (output_dir or Path.cwd()) / dir_name
38
+ pkg_dir.mkdir(parents=True, exist_ok=True)
39
+
40
+ # package.json
41
+ package_json = {
42
+ "name": name,
43
+ "version": "1.0.0",
44
+ "description": f"Shared ai-toolkit configuration for {_org_from_name(name)}",
45
+ "main": "ai-toolkit.config.json",
46
+ "files": ["ai-toolkit.config.json", "rules/", "agents/"],
47
+ "peerDependencies": {
48
+ "@softspark/ai-toolkit": ">=1.5.0"
49
+ },
50
+ "keywords": ["ai-toolkit", "config", "shared"],
51
+ }
52
+ _write_json(pkg_dir / "package.json", package_json)
53
+
54
+ # ai-toolkit.config.json (base config with sane defaults)
55
+ base_config: dict[str, Any] = {
56
+ "$schema": "https://softspark.github.io/ai-toolkit/schemas/ai-toolkit-config.json",
57
+ "name": name,
58
+ "version": "1.0.0",
59
+ "description": f"Base AI toolkit configuration for {_org_from_name(name)}",
60
+ "profile": "standard",
61
+ "agents": {
62
+ "enabled": [
63
+ "backend-specialist",
64
+ "code-reviewer",
65
+ "debugger",
66
+ "security-auditor",
67
+ "test-engineer",
68
+ ],
69
+ "disabled": [],
70
+ },
71
+ "rules": {
72
+ "inject": [],
73
+ },
74
+ "constitution": {
75
+ "amendments": [],
76
+ },
77
+ "enforce": {
78
+ "minHookProfile": "standard",
79
+ "requiredPlugins": [],
80
+ "forbidOverride": [],
81
+ "requiredAgents": [],
82
+ },
83
+ }
84
+ _write_json(pkg_dir / "ai-toolkit.config.json", base_config)
85
+
86
+ # rules/ directory with placeholder
87
+ rules_dir = pkg_dir / "rules"
88
+ rules_dir.mkdir(exist_ok=True)
89
+ (rules_dir / ".gitkeep").touch()
90
+
91
+ # agents/ directory with placeholder
92
+ agents_dir = pkg_dir / "agents"
93
+ agents_dir.mkdir(exist_ok=True)
94
+ (agents_dir / ".gitkeep").touch()
95
+
96
+ # README.md
97
+ readme = _generate_base_readme(name)
98
+ (pkg_dir / "README.md").write_text(readme, encoding="utf-8")
99
+
100
+ return pkg_dir
101
+
102
+
103
+ def _org_from_name(name: str) -> str:
104
+ """Extract org name from package name."""
105
+ if name.startswith("@"):
106
+ return name.split("/")[0].lstrip("@")
107
+ return name.split("-")[0] if "-" in name else name
108
+
109
+
110
+ def _generate_base_readme(name: str) -> str:
111
+ """Generate README for base config package."""
112
+ org = _org_from_name(name)
113
+ return f"""# {name}
114
+
115
+ Shared ai-toolkit configuration for {org}.
116
+
117
+ ## Usage
118
+
119
+ ### In your project
120
+
121
+ Create `.ai-toolkit.json` in your project root:
122
+
123
+ ```json
124
+ {{
125
+ "extends": "{name}",
126
+ "profile": "standard"
127
+ }}
128
+ ```
129
+
130
+ Then run:
131
+
132
+ ```bash
133
+ ai-toolkit install --local
134
+ ```
135
+
136
+ ### Available commands
137
+
138
+ ```bash
139
+ ai-toolkit config validate # Validate config + extends
140
+ ai-toolkit config diff # Show differences from base
141
+ ai-toolkit config check # CI enforcement check
142
+ ```
143
+
144
+ ## Customization
145
+
146
+ ### Adding rules
147
+
148
+ Add `.md` files to `rules/` and reference them in `ai-toolkit.config.json`:
149
+
150
+ ```json
151
+ {{
152
+ "rules": {{
153
+ "inject": ["./rules/your-rule.md"]
154
+ }}
155
+ }}
156
+ ```
157
+
158
+ ### Adding agents
159
+
160
+ Add agent `.md` files to `agents/` and reference them:
161
+
162
+ ```json
163
+ {{
164
+ "agents": {{
165
+ "custom": ["./agents/your-agent.md"]
166
+ }}
167
+ }}
168
+ ```
169
+
170
+ ### Enforcement
171
+
172
+ Use the `enforce` block to set non-overridable constraints:
173
+
174
+ ```json
175
+ {{
176
+ "enforce": {{
177
+ "minHookProfile": "standard",
178
+ "requiredAgents": ["security-auditor"],
179
+ "forbidOverride": ["constitution"],
180
+ "requiredPlugins": ["security-pack"]
181
+ }}
182
+ }}
183
+ ```
184
+
185
+ ## Publishing
186
+
187
+ ```bash
188
+ npm publish
189
+ ```
190
+
191
+ Projects using this config will pick up changes on `ai-toolkit update --local`.
192
+ """
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Project config generator
197
+ # ---------------------------------------------------------------------------
198
+
199
+ def create_project_config(
200
+ project_dir: Path,
201
+ extends: str = "",
202
+ profile: str = "standard",
203
+ ) -> Path:
204
+ """Generate .ai-toolkit.json for a project.
205
+
206
+ Args:
207
+ project_dir: Project root directory.
208
+ extends: Base config source (npm, git, local).
209
+ profile: Installation profile.
210
+
211
+ Returns:
212
+ Path to the created config file.
213
+ """
214
+ config: dict[str, Any] = {}
215
+
216
+ if extends:
217
+ config["extends"] = extends
218
+
219
+ config["profile"] = profile
220
+
221
+ config_path = project_dir / ".ai-toolkit.json"
222
+ _write_json(config_path, config)
223
+ return config_path
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Helpers
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def _write_json(path: Path, data: dict) -> None:
231
+ """Write JSON with consistent formatting."""
232
+ with open(path, "w", encoding="utf-8") as f:
233
+ json.dump(data, f, indent=2)
234
+ f.write("\n")
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # CLI entry point
239
+ # ---------------------------------------------------------------------------
240
+
241
+ def main() -> None:
242
+ """CLI: scaffold base config package."""
243
+ if len(sys.argv) < 2:
244
+ print("Usage: config_scaffold.py create-base <package-name> [output-dir]", file=sys.stderr)
245
+ sys.exit(1)
246
+
247
+ cmd = sys.argv[1]
248
+
249
+ if cmd == "create-base":
250
+ if len(sys.argv) < 3:
251
+ print("Usage: config_scaffold.py create-base <package-name> [output-dir]", file=sys.stderr)
252
+ sys.exit(1)
253
+ name = sys.argv[2]
254
+ output_dir = Path(sys.argv[3]) if len(sys.argv) > 3 else None
255
+ pkg_dir = create_base_package(name, output_dir)
256
+ print(json.dumps({
257
+ "created": str(pkg_dir),
258
+ "files": [str(p.relative_to(pkg_dir)) for p in sorted(pkg_dir.rglob("*")) if p.is_file()],
259
+ }, indent=2))
260
+ else:
261
+ print(f"Unknown command: {cmd}", file=sys.stderr)
262
+ sys.exit(1)
263
+
264
+
265
+ if __name__ == "__main__":
266
+ main()
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env python3
2
+ """Config validator for ai-toolkit extends system.
3
+
4
+ Validates .ai-toolkit.json against schema, checks enforce constraints,
5
+ verifies constitution integrity, and validates referenced files exist.
6
+
7
+ Stdlib-only — no external dependencies.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Constants
19
+ # ---------------------------------------------------------------------------
20
+
21
+ VALID_PROFILES = {"minimal", "standard", "strict", "full", "offline-slm"}
22
+ VALID_HOOK_PROFILES = {"minimal", "standard", "strict"}
23
+ HOOK_PROFILE_ORDER = {"minimal": 0, "standard": 1, "strict": 2}
24
+ IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5})
25
+ MIN_JUSTIFICATION_LEN = 20
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Exceptions
30
+ # ---------------------------------------------------------------------------
31
+
32
+ class ConfigValidationError(Exception):
33
+ """Raised when validation fails."""
34
+
35
+ def __init__(self, errors: list[str]) -> None:
36
+ self.errors = errors
37
+ super().__init__("\n".join(errors))
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Public API
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def validate_project_config(
45
+ config: dict[str, Any],
46
+ project_root: Path | None = None,
47
+ ) -> list[str]:
48
+ """Validate a project-level .ai-toolkit.json.
49
+
50
+ Returns list of error strings (empty = valid).
51
+ """
52
+ errors: list[str] = []
53
+
54
+ # Schema validation (structural)
55
+ _validate_schema(config, errors)
56
+
57
+ # File existence checks
58
+ if project_root:
59
+ _validate_file_references(config, project_root, errors)
60
+
61
+ return errors
62
+
63
+
64
+ def validate_base_config(
65
+ config: dict[str, Any],
66
+ config_root: Path | None = None,
67
+ ) -> list[str]:
68
+ """Validate a base ai-toolkit.config.json.
69
+
70
+ Returns list of error strings (empty = valid).
71
+ """
72
+ errors: list[str] = []
73
+
74
+ # Base configs should have name and version
75
+ if not config.get("name"):
76
+ errors.append("Base config missing required field 'name'.")
77
+ if not config.get("version"):
78
+ errors.append("Base config missing required field 'version'.")
79
+
80
+ # Schema validation
81
+ _validate_schema(config, errors)
82
+
83
+ # Enforce block validation
84
+ _validate_enforce_block(config.get("enforce", {}), errors)
85
+
86
+ # File existence checks
87
+ if config_root:
88
+ _validate_file_references(config, config_root, errors)
89
+
90
+ return errors
91
+
92
+
93
+ def validate_merged_config(
94
+ merged: dict[str, Any],
95
+ base: dict[str, Any],
96
+ ) -> list[str]:
97
+ """Validate a merged config against base enforce constraints.
98
+
99
+ Returns list of error strings (empty = valid).
100
+ """
101
+ errors: list[str] = []
102
+ enforce = base.get("enforce", {})
103
+
104
+ if not enforce:
105
+ return errors
106
+
107
+ _validate_enforce_constraints(merged, enforce, errors)
108
+
109
+ return errors
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Schema validation
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def _validate_schema(config: dict[str, Any], errors: list[str]) -> None:
117
+ """Validate structural correctness of config."""
118
+
119
+ # extends: must be string if present
120
+ extends = config.get("extends")
121
+ if extends is not None and not isinstance(extends, str):
122
+ errors.append(f"'extends' must be a string, got {type(extends).__name__}.")
123
+ if isinstance(extends, str) and not extends.strip():
124
+ errors.append("'extends' cannot be empty string.")
125
+
126
+ # profile: must be valid enum
127
+ profile = config.get("profile")
128
+ if profile is not None and profile not in VALID_PROFILES:
129
+ errors.append(
130
+ f"Invalid profile '{profile}'. Valid: {', '.join(sorted(VALID_PROFILES))}."
131
+ )
132
+
133
+ # agents: structural check
134
+ agents = config.get("agents")
135
+ if agents is not None:
136
+ _validate_agents_block(agents, errors)
137
+
138
+ # rules: structural check
139
+ rules = config.get("rules")
140
+ if rules is not None:
141
+ _validate_rules_block(rules, errors)
142
+
143
+ # constitution: structural check
144
+ constitution = config.get("constitution")
145
+ if constitution is not None:
146
+ _validate_constitution_block(constitution, errors)
147
+
148
+ # enforce: structural check
149
+ enforce = config.get("enforce")
150
+ if enforce is not None:
151
+ _validate_enforce_block(enforce, errors)
152
+
153
+ # overrides: structural check
154
+ overrides = config.get("overrides")
155
+ if overrides is not None:
156
+ _validate_overrides_block(overrides, errors)
157
+
158
+
159
+ def _validate_agents_block(agents: Any, errors: list[str]) -> None:
160
+ """Validate agents section structure."""
161
+ if not isinstance(agents, dict):
162
+ errors.append("'agents' must be an object.")
163
+ return
164
+
165
+ valid_keys = {"enabled", "disabled", "custom"}
166
+ unknown = set(agents.keys()) - valid_keys
167
+ if unknown:
168
+ errors.append(f"Unknown keys in 'agents': {', '.join(sorted(unknown))}.")
169
+
170
+ for key in ("enabled", "disabled", "custom"):
171
+ val = agents.get(key)
172
+ if val is not None:
173
+ if not isinstance(val, list):
174
+ errors.append(f"'agents.{key}' must be an array.")
175
+ elif not all(isinstance(item, str) for item in val):
176
+ errors.append(f"'agents.{key}' items must be strings.")
177
+
178
+
179
+ def _validate_rules_block(rules: Any, errors: list[str]) -> None:
180
+ """Validate rules section structure."""
181
+ if not isinstance(rules, dict):
182
+ errors.append("'rules' must be an object.")
183
+ return
184
+
185
+ valid_keys = {"inject", "remove"}
186
+ unknown = set(rules.keys()) - valid_keys
187
+ if unknown:
188
+ errors.append(f"Unknown keys in 'rules': {', '.join(sorted(unknown))}.")
189
+
190
+ for key in ("inject", "remove"):
191
+ val = rules.get(key)
192
+ if val is not None:
193
+ if not isinstance(val, list):
194
+ errors.append(f"'rules.{key}' must be an array.")
195
+ elif not all(isinstance(item, str) for item in val):
196
+ errors.append(f"'rules.{key}' items must be strings.")
197
+
198
+
199
+ def _validate_constitution_block(constitution: Any, errors: list[str]) -> None:
200
+ """Validate constitution section structure."""
201
+ if not isinstance(constitution, dict):
202
+ errors.append("'constitution' must be an object.")
203
+ return
204
+
205
+ amendments = constitution.get("amendments")
206
+ if amendments is None:
207
+ return
208
+ if not isinstance(amendments, list):
209
+ errors.append("'constitution.amendments' must be an array.")
210
+ return
211
+
212
+ seen_articles: set[int] = set()
213
+ for i, amendment in enumerate(amendments):
214
+ if not isinstance(amendment, dict):
215
+ errors.append(f"'constitution.amendments[{i}]' must be an object.")
216
+ continue
217
+
218
+ article = amendment.get("article")
219
+ if not isinstance(article, int) or article < 1:
220
+ errors.append(f"'constitution.amendments[{i}].article' must be a positive integer.")
221
+ continue
222
+
223
+ if article in seen_articles:
224
+ errors.append(f"Duplicate constitution article number: {article}.")
225
+ seen_articles.add(article)
226
+
227
+ if not amendment.get("title"):
228
+ errors.append(f"'constitution.amendments[{i}].title' is required.")
229
+ if not amendment.get("text"):
230
+ errors.append(f"'constitution.amendments[{i}].text' is required.")
231
+
232
+
233
+ def _validate_enforce_block(enforce: Any, errors: list[str]) -> None:
234
+ """Validate enforce section structure."""
235
+ if not isinstance(enforce, dict):
236
+ errors.append("'enforce' must be an object.")
237
+ return
238
+
239
+ valid_keys = {"minHookProfile", "requiredPlugins", "forbidOverride", "requiredAgents"}
240
+ unknown = set(enforce.keys()) - valid_keys
241
+ if unknown:
242
+ errors.append(f"Unknown keys in 'enforce': {', '.join(sorted(unknown))}.")
243
+
244
+ min_profile = enforce.get("minHookProfile")
245
+ if min_profile is not None and min_profile not in VALID_HOOK_PROFILES:
246
+ errors.append(
247
+ f"Invalid minHookProfile '{min_profile}'. "
248
+ f"Valid: {', '.join(sorted(VALID_HOOK_PROFILES))}."
249
+ )
250
+
251
+ for key in ("requiredPlugins", "forbidOverride", "requiredAgents"):
252
+ val = enforce.get(key)
253
+ if val is not None:
254
+ if not isinstance(val, list):
255
+ errors.append(f"'enforce.{key}' must be an array.")
256
+ elif not all(isinstance(item, str) for item in val):
257
+ errors.append(f"'enforce.{key}' items must be strings.")
258
+
259
+
260
+ def _validate_overrides_block(overrides: Any, errors: list[str]) -> None:
261
+ """Validate overrides section structure."""
262
+ if not isinstance(overrides, dict):
263
+ errors.append("'overrides' must be an object.")
264
+ return
265
+
266
+ for key, override in overrides.items():
267
+ if not isinstance(override, dict):
268
+ errors.append(f"'overrides.{key}' must be an object.")
269
+ continue
270
+
271
+ if not override.get("override"):
272
+ errors.append(f"'overrides.{key}' missing 'override: true'.")
273
+
274
+ justification = override.get("justification", "")
275
+ if not justification or len(justification) < MIN_JUSTIFICATION_LEN:
276
+ errors.append(
277
+ f"'overrides.{key}.justification' too short "
278
+ f"({len(justification)} chars, min {MIN_JUSTIFICATION_LEN})."
279
+ )
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Enforce constraint validation (post-merge)
284
+ # ---------------------------------------------------------------------------
285
+
286
+ def _validate_enforce_constraints(
287
+ merged: dict[str, Any],
288
+ enforce: dict[str, Any],
289
+ errors: list[str],
290
+ ) -> None:
291
+ """Check merged config against enforce constraints."""
292
+
293
+ # minHookProfile
294
+ min_profile = enforce.get("minHookProfile")
295
+ if min_profile:
296
+ profile_to_hook = {
297
+ "minimal": "minimal",
298
+ "standard": "standard",
299
+ "strict": "strict",
300
+ "full": "strict",
301
+ "offline-slm": "minimal",
302
+ }
303
+ merged_profile = merged.get("profile", "standard")
304
+ merged_hook = profile_to_hook.get(merged_profile, "standard")
305
+ if HOOK_PROFILE_ORDER.get(merged_hook, 1) < HOOK_PROFILE_ORDER.get(min_profile, 0):
306
+ errors.append(
307
+ f"Profile '{merged_profile}' (hook: {merged_hook}) "
308
+ f"below minimum '{min_profile}'."
309
+ )
310
+
311
+ # requiredAgents
312
+ required = set(enforce.get("requiredAgents", []))
313
+ if required:
314
+ enabled = set(merged.get("agents", {}).get("enabled", []))
315
+ missing = required - enabled
316
+ if missing:
317
+ errors.append(f"Required agents missing: {', '.join(sorted(missing))}.")
318
+
319
+ # requiredPlugins — informational in v1 (plugins field deferred)
320
+ required_plugins = enforce.get("requiredPlugins", [])
321
+ if required_plugins:
322
+ # v1: just warn, no plugins field to check against
323
+ pass
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # File reference validation
328
+ # ---------------------------------------------------------------------------
329
+
330
+ def _validate_file_references(
331
+ config: dict[str, Any],
332
+ root: Path,
333
+ errors: list[str],
334
+ ) -> None:
335
+ """Check that referenced files actually exist."""
336
+
337
+ # Custom agents
338
+ for agent_path in config.get("agents", {}).get("custom", []):
339
+ full = root / agent_path
340
+ if not full.is_file():
341
+ errors.append(f"Custom agent file not found: {agent_path} (resolved: {full})")
342
+
343
+ # Rule files
344
+ for rule_path in config.get("rules", {}).get("inject", []):
345
+ full = root / rule_path
346
+ if not full.is_file():
347
+ errors.append(f"Rule file not found: {rule_path} (resolved: {full})")
348
+
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # CLI entry point
352
+ # ---------------------------------------------------------------------------
353
+
354
+ def main() -> None:
355
+ """CLI: validate .ai-toolkit.json and print results."""
356
+ if len(sys.argv) < 2:
357
+ print("Usage: config_validator.py <config.json> [--strict]", file=sys.stderr)
358
+ sys.exit(1)
359
+
360
+ config_path = Path(sys.argv[1])
361
+ strict = "--strict" in sys.argv
362
+
363
+ try:
364
+ with open(config_path) as f:
365
+ config = json.load(f)
366
+ except (json.JSONDecodeError, OSError) as e:
367
+ print(json.dumps({"valid": False, "errors": [str(e)]}))
368
+ sys.exit(1)
369
+
370
+ project_root = config_path.parent
371
+ is_base = "name" in config and "version" in config
372
+
373
+ if is_base:
374
+ errors = validate_base_config(config, project_root)
375
+ else:
376
+ errors = validate_project_config(config, project_root)
377
+
378
+ if errors:
379
+ for e in errors:
380
+ print(f" ✗ {e}", file=sys.stderr)
381
+ if strict:
382
+ sys.exit(1)
383
+ print(json.dumps({"valid": False, "errors": errors}))
384
+ else:
385
+ print(json.dumps({"valid": True, "errors": []}))
386
+
387
+
388
+ if __name__ == "__main__":
389
+ main()