@softspark/ai-toolkit 4.15.0 → 4.16.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/AGENTS.md +117 -0
- package/CHANGELOG.md +43 -0
- package/README.md +19 -13
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +4 -3
- package/app/hooks/_hook-io.sh +18 -3
- package/app/hooks/ai-toolkit-statusline.sh +30 -5
- package/app/hooks/filter-tool-output.sh +76 -0
- package/app/hooks/governance-capture.sh +1 -1
- package/app/hooks/guard-path.sh +2 -2
- package/app/hooks/post-tool-use.sh +5 -3
- package/app/hooks/pre-compact-save.sh +4 -3
- package/app/hooks/quality-gate.sh +12 -1
- package/app/hooks/revert-guard.sh +5 -2
- package/app/hooks/save-session.sh +4 -2
- package/app/hooks/session-end.sh +36 -4
- package/app/hooks/session-start.sh +11 -5
- package/app/hooks.json +10 -0
- package/app/output-filter-policy.json +15 -0
- package/app/skills/brand-voice/scripts/measure.py +7 -5
- package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
- package/benchmarks/output-filter/README.md +11 -0
- package/benchmarks/output-filter/scenarios.json +25 -0
- package/bin/ai-toolkit.js +2 -0
- package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
- package/kb/procedures/release-preparation-sop.md +6 -5
- package/kb/reference/architecture-overview.md +6 -5
- package/kb/reference/cli-reference.md +19 -2
- package/kb/reference/codex-cli-compatibility.md +1 -0
- package/kb/reference/copilot-compatibility.md +173 -0
- package/kb/reference/enterprise-config-guide.md +28 -2
- package/kb/reference/global-install-model.md +6 -2
- package/kb/reference/hooks-catalog.md +105 -16
- package/kb/reference/opencode-compatibility.md +1 -0
- package/kb/reference/supported-tools-registry.md +10 -5
- package/kb/reference/tool-output-filter.md +288 -0
- package/kb/reference/windows-support.md +4 -3
- package/llms-full.txt +1182 -40
- package/llms.txt +3 -0
- package/manifest.json +9 -6
- package/package.json +3 -2
- package/scripts/benchmark_output_filter.py +343 -0
- package/scripts/check_deps.py +16 -0
- package/scripts/claude_app.py +30 -2
- package/scripts/config_cli.py +4 -4
- package/scripts/config_lock.py +120 -14
- package/scripts/config_merger.py +103 -20
- package/scripts/config_resolver.py +22 -2
- package/scripts/config_validator.py +268 -16
- package/scripts/copilot_legacy_hashes.json +338 -0
- package/scripts/doctor.py +1 -0
- package/scripts/generate_codex_hooks.py +2 -0
- package/scripts/generate_copilot.py +464 -71
- package/scripts/generate_copilot_hooks.py +124 -7
- package/scripts/generate_gemini_hooks.py +33 -10
- package/scripts/generate_opencode_plugin.py +28 -12
- package/scripts/install_steps/ai_tools.py +115 -3
- package/scripts/install_steps/hooks.py +25 -1
- package/scripts/output_filter_cli.py +347 -0
- package/scripts/output_filter_hook.py +23 -0
- package/scripts/plugin_schema.py +27 -1
- package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
- package/scripts/session_state.py +156 -42
- package/scripts/tool_output_filter/__init__.py +33 -0
- package/scripts/tool_output_filter/contracts.py +173 -0
- package/scripts/tool_output_filter/engine.py +260 -0
- package/scripts/tool_output_filter/hook_runtime.py +369 -0
- package/scripts/tool_output_filter/input.py +56 -0
- package/scripts/tool_output_filter/invariants.py +40 -0
- package/scripts/tool_output_filter/policy.py +153 -0
- package/scripts/tool_output_filter/profiles/__init__.py +68 -0
- package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
- package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
- package/scripts/tool_output_filter/recovery.py +846 -0
- package/scripts/tool_output_filter/telemetry.py +13 -0
- package/scripts/uninstall.py +96 -3
|
@@ -20,9 +20,18 @@ from typing import Any
|
|
|
20
20
|
|
|
21
21
|
VALID_PROFILES = {"minimal", "standard", "strict", "full", "offline-slm"}
|
|
22
22
|
VALID_HOOK_PROFILES = {"minimal", "standard", "strict"}
|
|
23
|
+
VALID_OUTPUT_FILTER_MODES = {"off", "observe", "safe"}
|
|
24
|
+
VALID_OUTPUT_FILTER_PROFILES = {"repeat-lines", "tap-success"}
|
|
25
|
+
MAX_OUTPUT_FILTER_INPUT_BYTES = 8_388_608
|
|
26
|
+
DEFAULT_OUTPUT_FILTER_MIN_SAVINGS_BYTES = 1_024
|
|
23
27
|
HOOK_PROFILE_ORDER = {"minimal": 0, "standard": 1, "strict": 2}
|
|
24
|
-
IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5, 6})
|
|
28
|
+
IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5, 6, 7})
|
|
25
29
|
MIN_JUSTIFICATION_LEN = 20
|
|
30
|
+
VALID_CONFIG_FIELDS = frozenset({
|
|
31
|
+
"$schema", "extends", "name", "version", "description", "profile",
|
|
32
|
+
"agents", "plugins", "rules", "constitution", "enforce", "overrides",
|
|
33
|
+
"toolOutputFilter",
|
|
34
|
+
})
|
|
26
35
|
|
|
27
36
|
|
|
28
37
|
# ---------------------------------------------------------------------------
|
|
@@ -116,6 +125,12 @@ def validate_merged_config(
|
|
|
116
125
|
def _validate_schema(config: dict[str, Any], errors: list[str]) -> None:
|
|
117
126
|
"""Validate structural correctness of config."""
|
|
118
127
|
|
|
128
|
+
unknown = set(config) - VALID_CONFIG_FIELDS
|
|
129
|
+
if unknown:
|
|
130
|
+
errors.append(
|
|
131
|
+
f"Unknown top-level config keys: {', '.join(sorted(unknown))}."
|
|
132
|
+
)
|
|
133
|
+
|
|
119
134
|
# extends: must be string if present
|
|
120
135
|
extends = config.get("extends")
|
|
121
136
|
if extends is not None and not isinstance(extends, str):
|
|
@@ -135,6 +150,10 @@ def _validate_schema(config: dict[str, Any], errors: list[str]) -> None:
|
|
|
135
150
|
if agents is not None:
|
|
136
151
|
_validate_agents_block(agents, errors)
|
|
137
152
|
|
|
153
|
+
plugins = config.get("plugins")
|
|
154
|
+
if plugins is not None:
|
|
155
|
+
_validate_plugins_block(plugins, errors)
|
|
156
|
+
|
|
138
157
|
# rules: structural check
|
|
139
158
|
rules = config.get("rules")
|
|
140
159
|
if rules is not None:
|
|
@@ -155,6 +174,10 @@ def _validate_schema(config: dict[str, Any], errors: list[str]) -> None:
|
|
|
155
174
|
if overrides is not None:
|
|
156
175
|
_validate_overrides_block(overrides, errors)
|
|
157
176
|
|
|
177
|
+
output_filter = config.get("toolOutputFilter")
|
|
178
|
+
if output_filter is not None:
|
|
179
|
+
_validate_output_filter_block(output_filter, errors)
|
|
180
|
+
|
|
158
181
|
|
|
159
182
|
def _validate_agents_block(agents: Any, errors: list[str]) -> None:
|
|
160
183
|
"""Validate agents section structure."""
|
|
@@ -176,6 +199,47 @@ def _validate_agents_block(agents: Any, errors: list[str]) -> None:
|
|
|
176
199
|
errors.append(f"'agents.{key}' items must be strings.")
|
|
177
200
|
|
|
178
201
|
|
|
202
|
+
def _validate_plugins_block(plugins: Any, errors: list[str]) -> None:
|
|
203
|
+
"""Validate effective plugin enable/disable intent."""
|
|
204
|
+
if not isinstance(plugins, dict):
|
|
205
|
+
errors.append("'plugins' must be an object.")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
valid_keys = {"enabled", "disabled"}
|
|
209
|
+
unknown = set(plugins) - valid_keys
|
|
210
|
+
if unknown:
|
|
211
|
+
errors.append(f"Unknown keys in 'plugins': {', '.join(sorted(unknown))}.")
|
|
212
|
+
|
|
213
|
+
for key in ("enabled", "disabled"):
|
|
214
|
+
value = plugins.get(key)
|
|
215
|
+
if value is not None:
|
|
216
|
+
if not isinstance(value, list):
|
|
217
|
+
errors.append(f"'plugins.{key}' must be an array.")
|
|
218
|
+
elif not all(
|
|
219
|
+
isinstance(item, str) and item.strip()
|
|
220
|
+
for item in value
|
|
221
|
+
):
|
|
222
|
+
errors.append(
|
|
223
|
+
f"'plugins.{key}' items must be non-empty strings."
|
|
224
|
+
)
|
|
225
|
+
elif len(value) != len(set(value)):
|
|
226
|
+
errors.append(f"'plugins.{key}' must not contain duplicates.")
|
|
227
|
+
|
|
228
|
+
enabled = plugins.get("enabled")
|
|
229
|
+
disabled = plugins.get("disabled")
|
|
230
|
+
if (
|
|
231
|
+
isinstance(enabled, list)
|
|
232
|
+
and isinstance(disabled, list)
|
|
233
|
+
and all(isinstance(item, str) for item in enabled + disabled)
|
|
234
|
+
):
|
|
235
|
+
overlap = set(enabled) & set(disabled)
|
|
236
|
+
if overlap:
|
|
237
|
+
errors.append(
|
|
238
|
+
"Plugins cannot be both enabled and disabled: "
|
|
239
|
+
f"{', '.join(sorted(overlap))}."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
179
243
|
def _validate_rules_block(rules: Any, errors: list[str]) -> None:
|
|
180
244
|
"""Validate rules section structure."""
|
|
181
245
|
if not isinstance(rules, dict):
|
|
@@ -220,6 +284,12 @@ def _validate_constitution_block(constitution: Any, errors: list[str]) -> None:
|
|
|
220
284
|
errors.append(f"'constitution.amendments[{i}].article' must be a positive integer.")
|
|
221
285
|
continue
|
|
222
286
|
|
|
287
|
+
if article in IMMUTABLE_ARTICLES:
|
|
288
|
+
errors.append(
|
|
289
|
+
f"Constitution Article {article} is reserved by ai-toolkit "
|
|
290
|
+
"(Articles I-VII); custom amendments must use article 8+."
|
|
291
|
+
)
|
|
292
|
+
|
|
223
293
|
if article in seen_articles:
|
|
224
294
|
errors.append(f"Duplicate constitution article number: {article}.")
|
|
225
295
|
seen_articles.add(article)
|
|
@@ -253,8 +323,15 @@ def _validate_enforce_block(enforce: Any, errors: list[str]) -> None:
|
|
|
253
323
|
if val is not None:
|
|
254
324
|
if not isinstance(val, list):
|
|
255
325
|
errors.append(f"'enforce.{key}' must be an array.")
|
|
256
|
-
elif not all(
|
|
257
|
-
|
|
326
|
+
elif not all(
|
|
327
|
+
isinstance(item, str) and item.strip()
|
|
328
|
+
for item in val
|
|
329
|
+
):
|
|
330
|
+
errors.append(
|
|
331
|
+
f"'enforce.{key}' items must be non-empty strings."
|
|
332
|
+
)
|
|
333
|
+
elif len(val) != len(set(val)):
|
|
334
|
+
errors.append(f"'enforce.{key}' must not contain duplicates.")
|
|
258
335
|
|
|
259
336
|
|
|
260
337
|
def _validate_overrides_block(overrides: Any, errors: list[str]) -> None:
|
|
@@ -279,6 +356,153 @@ def _validate_overrides_block(overrides: Any, errors: list[str]) -> None:
|
|
|
279
356
|
)
|
|
280
357
|
|
|
281
358
|
|
|
359
|
+
def _validate_output_filter_block(output_filter: Any, errors: list[str]) -> None:
|
|
360
|
+
"""Validate native tool-output filter configuration."""
|
|
361
|
+
if not isinstance(output_filter, dict):
|
|
362
|
+
errors.append("'toolOutputFilter' must be an object.")
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
valid_keys = {
|
|
366
|
+
"mode", "profiles", "maxInputBytes", "minSavingsBytes",
|
|
367
|
+
"minSavingsRatio", "recovery",
|
|
368
|
+
}
|
|
369
|
+
unknown = set(output_filter) - valid_keys
|
|
370
|
+
if unknown:
|
|
371
|
+
errors.append(
|
|
372
|
+
f"Unknown keys in 'toolOutputFilter': {', '.join(sorted(unknown))}."
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
mode = output_filter.get("mode")
|
|
376
|
+
if mode is not None and mode not in VALID_OUTPUT_FILTER_MODES:
|
|
377
|
+
errors.append(
|
|
378
|
+
f"Invalid 'toolOutputFilter.mode' '{mode}'. "
|
|
379
|
+
f"Valid: {', '.join(sorted(VALID_OUTPUT_FILTER_MODES))}."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
profiles = output_filter.get("profiles")
|
|
383
|
+
if profiles is not None:
|
|
384
|
+
if not isinstance(profiles, list) or not all(
|
|
385
|
+
isinstance(profile, str) for profile in profiles
|
|
386
|
+
):
|
|
387
|
+
errors.append("'toolOutputFilter.profiles' must be an array of strings.")
|
|
388
|
+
else:
|
|
389
|
+
invalid = set(profiles) - VALID_OUTPUT_FILTER_PROFILES
|
|
390
|
+
if invalid:
|
|
391
|
+
errors.append(
|
|
392
|
+
"Invalid 'toolOutputFilter.profiles': "
|
|
393
|
+
f"{', '.join(sorted(invalid))}."
|
|
394
|
+
)
|
|
395
|
+
if len(profiles) != len(set(profiles)):
|
|
396
|
+
errors.append(
|
|
397
|
+
"'toolOutputFilter.profiles' must not contain duplicate items."
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
_validate_output_filter_limits(output_filter, errors)
|
|
401
|
+
recovery = output_filter.get("recovery")
|
|
402
|
+
if recovery is not None:
|
|
403
|
+
_validate_output_filter_recovery(recovery, errors)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _validate_output_filter_limits(
|
|
407
|
+
output_filter: dict[str, Any],
|
|
408
|
+
errors: list[str],
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Validate output-filter byte and ratio bounds."""
|
|
411
|
+
_validate_bounded_integer(
|
|
412
|
+
output_filter.get("maxInputBytes"),
|
|
413
|
+
"toolOutputFilter.maxInputBytes",
|
|
414
|
+
errors,
|
|
415
|
+
minimum=1,
|
|
416
|
+
maximum=MAX_OUTPUT_FILTER_INPUT_BYTES,
|
|
417
|
+
)
|
|
418
|
+
_validate_bounded_integer(
|
|
419
|
+
output_filter.get("minSavingsBytes"),
|
|
420
|
+
"toolOutputFilter.minSavingsBytes",
|
|
421
|
+
errors,
|
|
422
|
+
minimum=0,
|
|
423
|
+
maximum=MAX_OUTPUT_FILTER_INPUT_BYTES,
|
|
424
|
+
)
|
|
425
|
+
_validate_output_filter_savings_relation(output_filter, errors)
|
|
426
|
+
|
|
427
|
+
ratio = output_filter.get("minSavingsRatio")
|
|
428
|
+
if ratio is not None and (
|
|
429
|
+
isinstance(ratio, bool)
|
|
430
|
+
or not isinstance(ratio, (int, float))
|
|
431
|
+
or not 0 <= ratio <= 1
|
|
432
|
+
):
|
|
433
|
+
errors.append("'toolOutputFilter.minSavingsRatio' must be between 0 and 1.")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _validate_output_filter_savings_relation(
|
|
437
|
+
output_filter: dict[str, Any],
|
|
438
|
+
errors: list[str],
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Ensure the savings threshold can be reached within the input limit."""
|
|
441
|
+
max_input = output_filter.get(
|
|
442
|
+
"maxInputBytes",
|
|
443
|
+
MAX_OUTPUT_FILTER_INPUT_BYTES,
|
|
444
|
+
)
|
|
445
|
+
min_savings = output_filter.get(
|
|
446
|
+
"minSavingsBytes",
|
|
447
|
+
DEFAULT_OUTPUT_FILTER_MIN_SAVINGS_BYTES,
|
|
448
|
+
)
|
|
449
|
+
values = (max_input, min_savings)
|
|
450
|
+
if any(
|
|
451
|
+
not isinstance(value, int) or isinstance(value, bool)
|
|
452
|
+
for value in values
|
|
453
|
+
):
|
|
454
|
+
return
|
|
455
|
+
if min_savings > max_input:
|
|
456
|
+
errors.append(
|
|
457
|
+
"'toolOutputFilter.minSavingsBytes' must not exceed "
|
|
458
|
+
"'toolOutputFilter.maxInputBytes'."
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _validate_output_filter_recovery(recovery: Any, errors: list[str]) -> None:
|
|
463
|
+
"""Validate secure ephemeral recovery settings."""
|
|
464
|
+
if not isinstance(recovery, dict):
|
|
465
|
+
errors.append("'toolOutputFilter.recovery' must be an object.")
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
valid_keys = {"mode", "ttlMinutes", "maxSessionBytes"}
|
|
469
|
+
unknown = set(recovery) - valid_keys
|
|
470
|
+
if unknown:
|
|
471
|
+
errors.append(
|
|
472
|
+
"Unknown keys in 'toolOutputFilter.recovery': "
|
|
473
|
+
f"{', '.join(sorted(unknown))}."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if recovery.get("mode") not in (None, "ephemeral"):
|
|
477
|
+
errors.append("'toolOutputFilter.recovery.mode' must be 'ephemeral'.")
|
|
478
|
+
for key in ("ttlMinutes", "maxSessionBytes"):
|
|
479
|
+
_validate_bounded_integer(
|
|
480
|
+
recovery.get(key),
|
|
481
|
+
f"toolOutputFilter.recovery.{key}",
|
|
482
|
+
errors,
|
|
483
|
+
minimum=1,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _validate_bounded_integer(
|
|
488
|
+
value: Any,
|
|
489
|
+
field: str,
|
|
490
|
+
errors: list[str],
|
|
491
|
+
*,
|
|
492
|
+
minimum: int,
|
|
493
|
+
maximum: int | None = None,
|
|
494
|
+
) -> None:
|
|
495
|
+
"""Validate an optional integer bound."""
|
|
496
|
+
if value is None:
|
|
497
|
+
return
|
|
498
|
+
is_valid = isinstance(value, int) and not isinstance(value, bool) and value >= minimum
|
|
499
|
+
if maximum is not None:
|
|
500
|
+
is_valid = is_valid and value <= maximum
|
|
501
|
+
if not is_valid:
|
|
502
|
+
suffix = f" and at most {maximum}" if maximum is not None else ""
|
|
503
|
+
errors.append(f"'{field}' must be an integer of at least {minimum}{suffix}.")
|
|
504
|
+
|
|
505
|
+
|
|
282
506
|
# ---------------------------------------------------------------------------
|
|
283
507
|
# Enforce constraint validation (post-merge)
|
|
284
508
|
# ---------------------------------------------------------------------------
|
|
@@ -316,11 +540,15 @@ def _validate_enforce_constraints(
|
|
|
316
540
|
if missing:
|
|
317
541
|
errors.append(f"Required agents missing: {', '.join(sorted(missing))}.")
|
|
318
542
|
|
|
319
|
-
|
|
320
|
-
required_plugins = enforce.get("requiredPlugins", [])
|
|
543
|
+
required_plugins = set(enforce.get("requiredPlugins", []))
|
|
321
544
|
if required_plugins:
|
|
322
|
-
|
|
323
|
-
|
|
545
|
+
enabled_plugins = set(merged.get("plugins", {}).get("enabled", []))
|
|
546
|
+
missing_plugins = required_plugins - enabled_plugins
|
|
547
|
+
if missing_plugins:
|
|
548
|
+
errors.append(
|
|
549
|
+
"Required plugins missing from enabled intent: "
|
|
550
|
+
f"{', '.join(sorted(missing_plugins))}."
|
|
551
|
+
)
|
|
324
552
|
|
|
325
553
|
|
|
326
554
|
# ---------------------------------------------------------------------------
|
|
@@ -335,16 +563,31 @@ def _validate_file_references(
|
|
|
335
563
|
"""Check that referenced files actually exist."""
|
|
336
564
|
|
|
337
565
|
# Custom agents
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
566
|
+
agents = config.get("agents")
|
|
567
|
+
custom_agents = agents.get("custom") if isinstance(agents, dict) else None
|
|
568
|
+
if isinstance(custom_agents, list):
|
|
569
|
+
for agent_path in custom_agents:
|
|
570
|
+
if not isinstance(agent_path, str):
|
|
571
|
+
continue
|
|
572
|
+
full = root / agent_path
|
|
573
|
+
if not full.is_file():
|
|
574
|
+
errors.append(
|
|
575
|
+
f"Custom agent file not found: {agent_path} "
|
|
576
|
+
f"(resolved: {full})"
|
|
577
|
+
)
|
|
342
578
|
|
|
343
579
|
# Rule files
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
580
|
+
rules = config.get("rules")
|
|
581
|
+
injected_rules = rules.get("inject") if isinstance(rules, dict) else None
|
|
582
|
+
if isinstance(injected_rules, list):
|
|
583
|
+
for rule_path in injected_rules:
|
|
584
|
+
if not isinstance(rule_path, str):
|
|
585
|
+
continue
|
|
586
|
+
full = root / rule_path
|
|
587
|
+
if not full.is_file():
|
|
588
|
+
errors.append(
|
|
589
|
+
f"Rule file not found: {rule_path} (resolved: {full})"
|
|
590
|
+
)
|
|
348
591
|
|
|
349
592
|
|
|
350
593
|
# ---------------------------------------------------------------------------
|
|
@@ -361,11 +604,20 @@ def main() -> None:
|
|
|
361
604
|
strict = "--strict" in sys.argv
|
|
362
605
|
|
|
363
606
|
try:
|
|
364
|
-
with open(config_path) as f:
|
|
607
|
+
with open(config_path, encoding="utf-8") as f:
|
|
365
608
|
config = json.load(f)
|
|
366
609
|
except (json.JSONDecodeError, OSError) as e:
|
|
367
610
|
print(json.dumps({"valid": False, "errors": [str(e)]}))
|
|
368
611
|
sys.exit(1)
|
|
612
|
+
except UnicodeDecodeError as e:
|
|
613
|
+
error = f"Cannot decode {config_path} as UTF-8: {e}"
|
|
614
|
+
print(json.dumps({"valid": False, "errors": [error]}))
|
|
615
|
+
sys.exit(1)
|
|
616
|
+
|
|
617
|
+
if not isinstance(config, dict):
|
|
618
|
+
error = f"{config_path} must contain a JSON object."
|
|
619
|
+
print(json.dumps({"valid": False, "errors": [error]}))
|
|
620
|
+
sys.exit(1)
|
|
369
621
|
|
|
370
622
|
project_root = config_path.parent
|
|
371
623
|
is_base = "name" in config and "version" in config
|