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