@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/scripts/install.py
CHANGED
|
@@ -57,6 +57,17 @@ from install_steps.install_state import (
|
|
|
57
57
|
print_status,
|
|
58
58
|
)
|
|
59
59
|
from install_steps.detect_language import detect_languages
|
|
60
|
+
from install_steps.project_registry import register_project
|
|
61
|
+
|
|
62
|
+
# Config inheritance (extends system)
|
|
63
|
+
from config_resolver import (
|
|
64
|
+
ConfigResolverError,
|
|
65
|
+
load_project_config,
|
|
66
|
+
resolve_extends,
|
|
67
|
+
)
|
|
68
|
+
from config_merger import ConfigMergeError, merge_config_chain
|
|
69
|
+
from config_validator import validate_project_config
|
|
70
|
+
from config_lock import save_lock_file
|
|
60
71
|
|
|
61
72
|
|
|
62
73
|
# ---------------------------------------------------------------------------
|
|
@@ -199,6 +210,8 @@ def parse_args(argv: list[str]) -> dict:
|
|
|
199
210
|
"status": False,
|
|
200
211
|
"lang": "",
|
|
201
212
|
"editors": "",
|
|
213
|
+
"config": "",
|
|
214
|
+
"refresh_base": False,
|
|
202
215
|
}
|
|
203
216
|
i = 0
|
|
204
217
|
while i < len(argv):
|
|
@@ -248,6 +261,13 @@ def parse_args(argv: list[str]) -> dict:
|
|
|
248
261
|
elif arg == "--editors":
|
|
249
262
|
i += 1
|
|
250
263
|
cfg["editors"] = argv[i] if i < len(argv) else ""
|
|
264
|
+
elif arg.startswith("--config="):
|
|
265
|
+
cfg["config"] = arg.split("=", 1)[1]
|
|
266
|
+
elif arg == "--config":
|
|
267
|
+
i += 1
|
|
268
|
+
cfg["config"] = argv[i] if i < len(argv) else ""
|
|
269
|
+
elif arg == "--refresh-base":
|
|
270
|
+
cfg["refresh_base"] = True
|
|
251
271
|
elif arg.startswith("-"):
|
|
252
272
|
print(f"Unknown option: {arg}")
|
|
253
273
|
sys.exit(1)
|
|
@@ -442,6 +462,117 @@ def install_strict_git_hooks(profile: str, local: bool, dry_run: bool) -> None:
|
|
|
442
462
|
# Version helper
|
|
443
463
|
# ---------------------------------------------------------------------------
|
|
444
464
|
|
|
465
|
+
def resolve_extends_config(
|
|
466
|
+
project_dir: Path,
|
|
467
|
+
config_path: str = "",
|
|
468
|
+
refresh: bool = False,
|
|
469
|
+
) -> dict | None:
|
|
470
|
+
"""Resolve .ai-toolkit.json extends and return merged config.
|
|
471
|
+
|
|
472
|
+
Returns None if no .ai-toolkit.json or no extends field.
|
|
473
|
+
Prints warnings/errors and exits on fatal errors.
|
|
474
|
+
"""
|
|
475
|
+
if config_path:
|
|
476
|
+
config_file = Path(config_path)
|
|
477
|
+
if not config_file.is_file():
|
|
478
|
+
print(f" Error: config file not found: {config_path}")
|
|
479
|
+
sys.exit(1)
|
|
480
|
+
import json as _json
|
|
481
|
+
with open(config_file, encoding="utf-8") as f:
|
|
482
|
+
project_config = _json.load(f)
|
|
483
|
+
config_root = config_file.parent
|
|
484
|
+
else:
|
|
485
|
+
project_config = load_project_config(project_dir)
|
|
486
|
+
config_root = project_dir
|
|
487
|
+
|
|
488
|
+
if project_config is None:
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
# Validate project config schema
|
|
492
|
+
errors = validate_project_config(project_config, config_root)
|
|
493
|
+
if errors:
|
|
494
|
+
print(" Config validation errors:")
|
|
495
|
+
for e in errors:
|
|
496
|
+
print(f" ✗ {e}")
|
|
497
|
+
sys.exit(1)
|
|
498
|
+
|
|
499
|
+
extends = project_config.get("extends")
|
|
500
|
+
if not extends:
|
|
501
|
+
# Config without extends — just use its settings directly
|
|
502
|
+
return project_config
|
|
503
|
+
|
|
504
|
+
print(f" Resolving extends: {extends}...")
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
result = resolve_extends(extends, config_root, refresh=refresh)
|
|
508
|
+
except ConfigResolverError as e:
|
|
509
|
+
print(f" ✗ Resolution failed: {e}")
|
|
510
|
+
sys.exit(1)
|
|
511
|
+
|
|
512
|
+
for w in result.warnings:
|
|
513
|
+
print(f" ⚠ {w}")
|
|
514
|
+
|
|
515
|
+
# Merge
|
|
516
|
+
try:
|
|
517
|
+
base_datas = [c.data for c in result.configs]
|
|
518
|
+
merge_result = merge_config_chain(base_datas, project_config)
|
|
519
|
+
except ConfigMergeError as e:
|
|
520
|
+
print(f" ✗ Merge failed: {e}")
|
|
521
|
+
sys.exit(1)
|
|
522
|
+
|
|
523
|
+
for c in result.configs:
|
|
524
|
+
version_str = f" v{c.version}" if c.version else ""
|
|
525
|
+
print(f" ✓ Resolved: {c.name}{version_str}")
|
|
526
|
+
|
|
527
|
+
# Attach resolution metadata for state.json recording
|
|
528
|
+
config_metas = [
|
|
529
|
+
{
|
|
530
|
+
"source": c.source,
|
|
531
|
+
"name": c.name,
|
|
532
|
+
"version": c.version,
|
|
533
|
+
"integrity": c.integrity,
|
|
534
|
+
"root": str(c.root),
|
|
535
|
+
}
|
|
536
|
+
for c in result.configs
|
|
537
|
+
]
|
|
538
|
+
merge_result.merged["_extends_meta"] = {
|
|
539
|
+
"source": extends,
|
|
540
|
+
"configs": config_metas,
|
|
541
|
+
"overrides_applied": merge_result.overrides_applied,
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
# Generate lock file
|
|
545
|
+
lock_path = save_lock_file(
|
|
546
|
+
config_root,
|
|
547
|
+
config_metas,
|
|
548
|
+
ai_toolkit_version=_get_toolkit_version(),
|
|
549
|
+
)
|
|
550
|
+
print(f" Saved: {lock_path.name}")
|
|
551
|
+
|
|
552
|
+
return merge_result.merged
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _apply_merged_config(
|
|
556
|
+
merged: dict,
|
|
557
|
+
cfg: dict,
|
|
558
|
+
) -> dict:
|
|
559
|
+
"""Apply merged config settings back into the install cfg dict.
|
|
560
|
+
|
|
561
|
+
Overrides profile and modules based on merged config.
|
|
562
|
+
Returns the modified cfg.
|
|
563
|
+
"""
|
|
564
|
+
# Profile from merged config
|
|
565
|
+
merged_profile = merged.get("profile")
|
|
566
|
+
if merged_profile and not cfg["profile"]:
|
|
567
|
+
cfg["profile"] = merged_profile
|
|
568
|
+
|
|
569
|
+
# Agents: filter based on merged enabled/disabled lists
|
|
570
|
+
# (stored for use by install_local_project)
|
|
571
|
+
cfg["_merged_config"] = merged
|
|
572
|
+
|
|
573
|
+
return cfg
|
|
574
|
+
|
|
575
|
+
|
|
445
576
|
def _get_toolkit_version() -> str:
|
|
446
577
|
"""Read version from package.json."""
|
|
447
578
|
pkg = toolkit_dir / "package.json"
|
|
@@ -522,10 +653,21 @@ def main() -> None:
|
|
|
522
653
|
|
|
523
654
|
if local:
|
|
524
655
|
# --local: project-local only, no global install
|
|
656
|
+
# Check for .ai-toolkit.json extends system
|
|
657
|
+
config_path_arg: str = cfg["config"]
|
|
658
|
+
refresh_base: bool = cfg["refresh_base"]
|
|
659
|
+
merged_config = resolve_extends_config(
|
|
660
|
+
project_dir, config_path=config_path_arg, refresh=refresh_base,
|
|
661
|
+
)
|
|
662
|
+
if merged_config:
|
|
663
|
+
cfg = _apply_merged_config(merged_config, cfg)
|
|
664
|
+
profile = cfg["profile"]
|
|
665
|
+
|
|
525
666
|
lang_modules = [m for m in (resolved_modules or []) if m.startswith("rules-")]
|
|
526
667
|
editors_arg: str = cfg["editors"]
|
|
527
668
|
install_local_project(rules_dir, dry_run, reset, lang_modules or None,
|
|
528
|
-
editors=editors_arg
|
|
669
|
+
editors=editors_arg,
|
|
670
|
+
merged_config=merged_config)
|
|
529
671
|
install_strict_git_hooks(profile, local, dry_run)
|
|
530
672
|
else:
|
|
531
673
|
# Global install
|
|
@@ -549,13 +691,33 @@ def main() -> None:
|
|
|
549
691
|
# Legacy mode: infer modules from profile/only
|
|
550
692
|
record_modules = _infer_modules_from_legacy(profile, only)
|
|
551
693
|
|
|
694
|
+
# Extract extends metadata if available
|
|
695
|
+
extends_info = None
|
|
696
|
+
merged = cfg.get("_merged_config")
|
|
697
|
+
if merged and merged.get("_extends_meta"):
|
|
698
|
+
extends_info = merged["_extends_meta"]
|
|
699
|
+
|
|
552
700
|
record_install(
|
|
553
701
|
version=_get_toolkit_version(),
|
|
554
702
|
modules=record_modules,
|
|
555
703
|
profile=profile or "standard",
|
|
556
704
|
auto_detected=auto_detected,
|
|
705
|
+
extends_info=extends_info,
|
|
557
706
|
)
|
|
558
707
|
|
|
708
|
+
# Register project in global registry (for `ai-toolkit update` propagation)
|
|
709
|
+
if local:
|
|
710
|
+
extends_source = ""
|
|
711
|
+
if extends_info:
|
|
712
|
+
extends_source = extends_info.get("source", "")
|
|
713
|
+
is_new = register_project(
|
|
714
|
+
project_dir,
|
|
715
|
+
profile=profile or "standard",
|
|
716
|
+
extends=extends_source,
|
|
717
|
+
)
|
|
718
|
+
if is_new:
|
|
719
|
+
print(f" Registered project in ~/.ai-toolkit/projects.json")
|
|
720
|
+
|
|
559
721
|
print_summary(local=local)
|
|
560
722
|
|
|
561
723
|
|
|
@@ -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 = ""
|
|
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,142 @@
|
|
|
1
|
+
"""Project registry — tracks which directories have ai-toolkit installed locally.
|
|
2
|
+
|
|
3
|
+
Stores registry in ~/.ai-toolkit/projects.json.
|
|
4
|
+
Used by `ai-toolkit update` to propagate updates to all registered projects,
|
|
5
|
+
and by `ai-toolkit projects` to list/manage them.
|
|
6
|
+
|
|
7
|
+
Stdlib-only — no external dependencies.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
REGISTRY_FILENAME = "projects.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _registry_path() -> Path:
|
|
22
|
+
"""Return the canonical path to projects.json."""
|
|
23
|
+
return Path(os.environ.get("AI_TOOLKIT_HOME", Path.home() / ".ai-toolkit")) / REGISTRY_FILENAME
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now_iso() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Load / Save
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def load_registry() -> list[dict[str, Any]]:
|
|
35
|
+
"""Load project registry. Returns empty list if missing/corrupt."""
|
|
36
|
+
path = _registry_path()
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
return []
|
|
39
|
+
try:
|
|
40
|
+
with open(path, encoding="utf-8") as f:
|
|
41
|
+
data = json.load(f)
|
|
42
|
+
if isinstance(data, dict):
|
|
43
|
+
projects = data.get("projects", [])
|
|
44
|
+
return projects if isinstance(projects, list) else []
|
|
45
|
+
return []
|
|
46
|
+
except (json.JSONDecodeError, OSError):
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_registry(projects: list[dict[str, Any]]) -> None:
|
|
51
|
+
"""Save project registry."""
|
|
52
|
+
path = _registry_path()
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
55
|
+
json.dump({"projects": projects}, f, indent=2)
|
|
56
|
+
f.write("\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# CRUD
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def register_project(
|
|
64
|
+
project_path: str | Path,
|
|
65
|
+
profile: str = "",
|
|
66
|
+
extends: str = "",
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""Register a project directory. Returns True if newly added, False if updated.
|
|
69
|
+
|
|
70
|
+
Idempotent — updates existing entry if path already registered.
|
|
71
|
+
"""
|
|
72
|
+
project_path = str(Path(project_path).resolve())
|
|
73
|
+
projects = load_registry()
|
|
74
|
+
now = _now_iso()
|
|
75
|
+
|
|
76
|
+
for p in projects:
|
|
77
|
+
if p.get("path") == project_path:
|
|
78
|
+
# Update existing
|
|
79
|
+
p["last_updated"] = now
|
|
80
|
+
if profile:
|
|
81
|
+
p["profile"] = profile
|
|
82
|
+
if extends:
|
|
83
|
+
p["extends"] = extends
|
|
84
|
+
elif "extends" in p and not extends:
|
|
85
|
+
# Clear extends if project no longer uses it
|
|
86
|
+
pass
|
|
87
|
+
save_registry(projects)
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
# New registration
|
|
91
|
+
projects.append({
|
|
92
|
+
"path": project_path,
|
|
93
|
+
"registered_at": now,
|
|
94
|
+
"last_updated": now,
|
|
95
|
+
"profile": profile or "standard",
|
|
96
|
+
"extends": extends or "",
|
|
97
|
+
})
|
|
98
|
+
save_registry(projects)
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def unregister_project(project_path: str | Path) -> bool:
|
|
103
|
+
"""Unregister a project. Returns True if found and removed."""
|
|
104
|
+
project_path = str(Path(project_path).resolve())
|
|
105
|
+
projects = load_registry()
|
|
106
|
+
original_len = len(projects)
|
|
107
|
+
projects = [p for p in projects if p.get("path") != project_path]
|
|
108
|
+
if len(projects) < original_len:
|
|
109
|
+
save_registry(projects)
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def list_projects() -> list[dict[str, Any]]:
|
|
115
|
+
"""List all registered projects with existence status."""
|
|
116
|
+
projects = load_registry()
|
|
117
|
+
for p in projects:
|
|
118
|
+
p["exists"] = Path(p["path"]).is_dir()
|
|
119
|
+
return projects
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def prune_stale() -> list[str]:
|
|
123
|
+
"""Remove projects whose directories no longer exist. Returns pruned paths."""
|
|
124
|
+
projects = load_registry()
|
|
125
|
+
pruned: list[str] = []
|
|
126
|
+
kept: list[dict[str, Any]] = []
|
|
127
|
+
|
|
128
|
+
for p in projects:
|
|
129
|
+
if Path(p["path"]).is_dir():
|
|
130
|
+
kept.append(p)
|
|
131
|
+
else:
|
|
132
|
+
pruned.append(p["path"])
|
|
133
|
+
|
|
134
|
+
if pruned:
|
|
135
|
+
save_registry(kept)
|
|
136
|
+
|
|
137
|
+
return pruned
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_active_projects() -> list[dict[str, Any]]:
|
|
141
|
+
"""Get registered projects that still exist on disk."""
|
|
142
|
+
return [p for p in load_registry() if Path(p["path"]).is_dir()]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI for managing the ai-toolkit project registry.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
ai-toolkit projects — List all registered projects
|
|
6
|
+
ai-toolkit projects --prune — Remove stale (deleted) projects
|
|
7
|
+
ai-toolkit projects remove <path> — Unregister a specific project
|
|
8
|
+
|
|
9
|
+
Stdlib-only — no external dependencies.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
from install_steps.project_registry import (
|
|
18
|
+
list_projects,
|
|
19
|
+
prune_stale,
|
|
20
|
+
unregister_project,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> None:
|
|
25
|
+
args = sys.argv[1:]
|
|
26
|
+
|
|
27
|
+
if not args or args == []:
|
|
28
|
+
cmd_list()
|
|
29
|
+
elif args[0] == "--prune":
|
|
30
|
+
cmd_prune()
|
|
31
|
+
elif args[0] == "remove" and len(args) >= 2:
|
|
32
|
+
cmd_remove(args[1])
|
|
33
|
+
elif args[0] in ("--help", "-h", "help"):
|
|
34
|
+
cmd_help()
|
|
35
|
+
else:
|
|
36
|
+
print(f"Unknown: {' '.join(args)}", file=sys.stderr)
|
|
37
|
+
cmd_help()
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_list() -> None:
|
|
42
|
+
"""List all registered projects."""
|
|
43
|
+
projects = list_projects()
|
|
44
|
+
|
|
45
|
+
if not projects:
|
|
46
|
+
print(" No registered projects.")
|
|
47
|
+
print(" Run 'ai-toolkit install --local' in a project to register it.")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
print(f" Registered projects ({len(projects)}):")
|
|
51
|
+
print()
|
|
52
|
+
|
|
53
|
+
for p in projects:
|
|
54
|
+
path_short = p["path"].replace(str(Path.home()), "~")
|
|
55
|
+
status = "✓" if p["exists"] else "✗ MISSING"
|
|
56
|
+
profile = p.get("profile", "")
|
|
57
|
+
extends = p.get("extends", "")
|
|
58
|
+
updated = p.get("last_updated", "")
|
|
59
|
+
|
|
60
|
+
print(f" {status} {path_short}")
|
|
61
|
+
details = []
|
|
62
|
+
if profile:
|
|
63
|
+
details.append(f"profile: {profile}")
|
|
64
|
+
if extends:
|
|
65
|
+
details.append(f"extends: {extends}")
|
|
66
|
+
if updated:
|
|
67
|
+
details.append(f"updated: {updated}")
|
|
68
|
+
if details:
|
|
69
|
+
print(f" {' | '.join(details)}")
|
|
70
|
+
|
|
71
|
+
stale = [p for p in projects if not p["exists"]]
|
|
72
|
+
if stale:
|
|
73
|
+
print()
|
|
74
|
+
print(f" {len(stale)} stale project(s). Run 'ai-toolkit projects --prune' to clean up.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_prune() -> None:
|
|
78
|
+
"""Remove stale projects."""
|
|
79
|
+
pruned = prune_stale()
|
|
80
|
+
if pruned:
|
|
81
|
+
for p in pruned:
|
|
82
|
+
path_short = p.replace(str(Path.home()), "~")
|
|
83
|
+
print(f" Pruned: {path_short}")
|
|
84
|
+
print(f" Removed {len(pruned)} stale project(s).")
|
|
85
|
+
else:
|
|
86
|
+
print(" No stale projects found.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_remove(project_path: str) -> None:
|
|
90
|
+
"""Unregister a specific project."""
|
|
91
|
+
resolved = Path(project_path).resolve()
|
|
92
|
+
if unregister_project(resolved):
|
|
93
|
+
path_short = str(resolved).replace(str(Path.home()), "~")
|
|
94
|
+
print(f" Removed: {path_short}")
|
|
95
|
+
else:
|
|
96
|
+
print(f" Not found in registry: {project_path}")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_help() -> None:
|
|
101
|
+
print("Usage: ai-toolkit projects [command]")
|
|
102
|
+
print()
|
|
103
|
+
print("Commands:")
|
|
104
|
+
print(" (none) List all registered projects")
|
|
105
|
+
print(" --prune Remove projects whose directories no longer exist")
|
|
106
|
+
print(" remove <path> Unregister a specific project")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|