@softspark/ai-toolkit 1.8.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 CHANGED
@@ -7,6 +7,21 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v1.9.0 — Project Registry & Doc Sync (2026-04-12)
11
+
12
+ ### Added
13
+ - **Project registry** — `install --local` automatically registers project path in `~/.ai-toolkit/projects.json`. `ai-toolkit update` propagates updates to all registered projects in parallel via `ThreadPoolExecutor`
14
+ - **`ai-toolkit projects`** — list registered projects, `--prune` to remove stale (deleted directories), `remove <path>` to unregister specific project
15
+ - **Parallel update propagation** — `ai-toolkit update` (global) now auto-updates all registered local projects concurrently (max 8 workers)
16
+
17
+ ### Fixed
18
+ - **README.md** — skill count 91→92 in comparison table, added Augment/Antigravity to cross-tool tables, fixed Notification hook description (inline→`notify-waiting.sh`)
19
+ - **README.md** — added Config Inheritance section, Project Registry section, 6 new CLI commands to reference table
20
+ - **ARCHITECTURE.md** — added Config Inheritance and Project Registry to Extension Points
21
+ - **CLAUDE.md** — added `config` and `projects` commands
22
+
23
+ ---
24
+
10
25
  ## v1.8.0 — Enterprise Config Inheritance (2026-04-12)
11
26
 
12
27
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
  [![Skills](https://img.shields.io/badge/skills-92-brightgreen)](app/skills/)
8
8
  [![Agents](https://img.shields.io/badge/agents-44-blue)](app/agents/)
9
- [![Tests](https://img.shields.io/badge/tests-564%20passing-success)](tests/)
9
+ [![Tests](https://img.shields.io/badge/tests-575%20passing-success)](tests/)
10
10
 
11
11
  ---
12
12
 
@@ -318,7 +318,7 @@ Hook logic lives in `app/hooks/*.sh` — not inline JSON one-liners. Scripts are
318
318
  | SessionStart | `session-start.sh` | MANDATORY rules reminder + session context + instincts |
319
319
  | SessionStart | `mcp-health.sh` | Check MCP server command availability (non-blocking warning) |
320
320
  | SessionStart | `session-context.sh` | Capture environment snapshot (pwd, git branch, versions) to `~/.ai-toolkit/sessions/current-context.json` |
321
- | Notification | *(inline)* | macOS desktop notification |
321
+ | Notification | `notify-waiting.sh` | Cross-platform desktop notification |
322
322
  | PreToolUse | `guard-destructive.sh` | Block `rm -rf`, `DROP TABLE`, etc. |
323
323
  | PreToolUse | `guard-path.sh` | Block wrong-user path hallucination |
324
324
  | PreToolUse | `guard-config.sh` | Block edits to linter/formatter config files unless explicitly requested |
@@ -612,18 +612,62 @@ All packs have `status: experimental`. Each has a `plugin.json` manifest and `RE
612
612
 
613
613
  ---
614
614
 
615
+ ## Config Inheritance (`extends`)
616
+
617
+ Enterprise-grade configuration inheritance for multi-repo AI governance. Organizations define a shared base config published as an npm package, Git URL, or local path. Projects inherit via `.ai-toolkit.json`:
618
+
619
+ ```json
620
+ {
621
+ "extends": "@mycompany/ai-toolkit-config",
622
+ "profile": "standard"
623
+ }
624
+ ```
625
+
626
+ **Key capabilities:**
627
+ - **Layered merge** — base config + project overrides, with deep merge for dicts, union for lists, project-wins for scalars
628
+ - **Constitution immutability** — Articles I-V and base articles cannot be modified; projects can only ADD new articles (6+)
629
+ - **Enforce constraints** — `requiredAgents`, `forbidOverride`, `minHookProfile`, `requiredPlugins`
630
+ - **Override validation** — requires explicit `override: true` + justification (min 20 chars)
631
+ - **Lock file** — `.ai-toolkit.lock.json` pins resolved versions for reproducible installs
632
+ - **Offline fallback** — uses cached configs from `~/.ai-toolkit/config-cache/` when registry unavailable
633
+
634
+ ```bash
635
+ ai-toolkit config create-base @mycompany/ai-toolkit-config # scaffold base package
636
+ ai-toolkit config init --extends @mycompany/ai-toolkit-config # setup project
637
+ ai-toolkit config validate # schema + extends + enforcement
638
+ ai-toolkit config diff # project vs base differences
639
+ ai-toolkit config check # CI enforcement gate (exit 0/1/2, --json)
640
+ ```
641
+
642
+ See [Enterprise Config Guide](kb/reference/enterprise-config-guide.md) for full documentation.
643
+
644
+ ---
645
+
646
+ ## Project Registry
647
+
648
+ All projects installed with `--local` are automatically registered in `~/.ai-toolkit/projects.json`. Running `ai-toolkit update` propagates updates to all registered projects in parallel.
649
+
650
+ ```bash
651
+ ai-toolkit projects # list registered projects
652
+ ai-toolkit projects --prune # remove stale (deleted) entries
653
+ ai-toolkit projects remove /path # unregister specific project
654
+ ai-toolkit update # global update + parallel update ALL projects
655
+ ```
656
+
657
+ ---
658
+
615
659
  ## Comparison
616
660
 
617
661
  | Feature | ai-toolkit | everything-claude-code | wshobson/agents | ruflo |
618
662
  |---------|---------------|----------------------|-----------------|-------|
619
- | Skills | 91 | 100+ | 146 | 20+ |
663
+ | Skills | 92 | 100+ | 146 | 20+ |
620
664
  | Agents | 44 | 30+ | 112 | 20+ |
621
665
  | Machine-enforced constitution | **Yes** | No (docs only) | No | No |
622
666
  | Skill-scoped lifecycle hooks | **Yes** | No | No | No |
623
667
  | Effort-based model budgeting | **Yes** | No | No | No |
624
668
  | Test suite | Yes (bats) | Yes (997 tests) | No | Yes |
625
669
  | npm/npx install | Yes | Yes | Yes | Yes |
626
- | Cross-tool support | **Cursor, Windsurf, Copilot, Gemini, Cline, Roo, Aider, Codex** | 5+ tools | Smithery | Limited |
670
+ | Cross-tool support | **Cursor, Windsurf, Copilot, Gemini, Cline, Roo, Aider, Augment, Antigravity** | 5+ tools | Smithery | Limited |
627
671
  | Selective install | Yes | Yes | Yes (72 plugins) | No |
628
672
  | Session persistence | Yes | Yes | No | No |
629
673
  | Architecture notes | **Yes** | No | No | No |
@@ -662,6 +706,8 @@ Pre-configured team presets via `/teams`:
662
706
  | Cline | `.clinerules` | project |
663
707
  | Roo Code | `.roomodes` | project |
664
708
  | Aider | `.aider.conf.yml` | project |
709
+ | Augment | `.augment/rules/ai-toolkit-*.md` | project |
710
+ | Google Antigravity | `.agent/rules/` + `.agent/workflows/` | project |
665
711
  | Codex / OpenCode | `AGENTS.md` | project |
666
712
 
667
713
  ```bash
@@ -764,8 +810,14 @@ Usage: ai-toolkit <command> [options]
764
810
  | `mcp add <name> [names...]` | Add MCP server template(s) to `.mcp.json` |
765
811
  | `mcp show <name>` | Show MCP template config details |
766
812
  | `mcp remove <name>` | Remove MCP server from `.mcp.json` |
813
+ | `config validate [path]` | Validate `.ai-toolkit.json` schema + extends + enforcement |
814
+ | `config diff [path]` | Show project vs base config differences |
815
+ | `config init [flags]` | Create `.ai-toolkit.json` (`--extends`, `--profile`, `--no-extends`) |
816
+ | `config create-base <name>` | Scaffold base config npm package |
817
+ | `config check [path]` | CI enforcement gate (exit 0=pass, 1=fail, 2=no config; `--json`) |
818
+ | `projects` | List registered projects (`--prune` to clean stale, `remove <path>`) |
767
819
  | `status` | Show installed modules and version |
768
- | `update` | Re-install with saved modules (incremental) |
820
+ | `update` | Re-install with saved modules + update all registered projects |
769
821
  | `validate` | Verify toolkit integrity (`--strict` for CI-grade, warnings = errors) |
770
822
  | `doctor` | Diagnose install health, hooks, quick-win assets, and artifact drift |
771
823
  | `doctor --fix` | Auto-repair broken symlinks, missing hooks, stale artifacts |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-toolkit",
3
3
  "description": "Professional-grade Claude Code toolkit with persona presets, skill security auditor, expanded lifecycle hooks, experimental opt-in plugin packs, benchmark harvesting, and multi-tool support.",
4
- "version": "1.8.0",
4
+ "version": "1.9.0",
5
5
  "author": {
6
6
  "name": "SoftSpark",
7
7
  "url": "https://github.com/softspark"
@@ -315,6 +315,12 @@ The `inject_section_cli.py` script provides a stable marker-based injection API.
315
315
  ### SLM Compilation (`compile-slm`)
316
316
  `scripts/compile_slm.py` compiles the full toolkit (20K+ tokens) into a minimal system prompt for Small Language Models (2K-16K tokens). Pipeline: Parse → Score → Compress → Pack → Emit. Supports 4 compression levels (ultra-light, light, standard, extended), 4 output formats (raw, ollama, json-string, aider), persona-aware scoring, and language-aware rule filtering. Profile `offline-slm` in `manifest.json`. Constitution is always included (non-negotiable).
317
317
 
318
+ ### Config Inheritance (`extends`)
319
+ `scripts/config_resolver.py`, `config_merger.py`, `config_validator.py`, `config_cli.py`, `config_scaffold.py`, `config_lock.py`. Enterprise configuration inheritance via `.ai-toolkit.json` `extends` field. Resolves base configs from npm packages, Git URLs, or local paths. Layered deep merge with constitution immutability (Articles I-V absolute), enforce constraints (`requiredAgents`, `forbidOverride`, `minHookProfile`), override validation (`override: true` + justification), and lock file (`.ai-toolkit.lock.json`). CLI: `config validate`, `config diff`, `config init`, `config create-base`, `config check`. Integrated into `install --local` and `update --local` flows.
320
+
321
+ ### Project Registry
322
+ `scripts/install_steps/project_registry.py`, `scripts/update_projects.py`, `scripts/projects_cli.py`. Tracks all `--local` installed projects in `~/.ai-toolkit/projects.json`. `ai-toolkit update` propagates to all registered projects in parallel via `ThreadPoolExecutor`. CLI: `ai-toolkit projects`, `--prune`, `remove <path>`.
323
+
318
324
  ### Manifest Install (`--modules`, `--auto-detect`)
319
325
  `manifest.json` defines all installable components as named modules. Install individual modules with `ai-toolkit install --modules <name>` or let the installer detect which language rules to add based on project files (e.g. `package.json` → `rules-typescript`, `go.mod` → `rules-golang`).
320
326
 
package/bin/ai-toolkit.js CHANGED
@@ -76,6 +76,7 @@ const COMMANDS = {
76
76
  create: 'Scaffold new skill from template (e.g. create skill my-lint --template=linter)',
77
77
  mcp: 'Manage MCP server templates (list, show, add, remove)',
78
78
  config: 'Manage config inheritance (validate, diff, init, create-base, check)',
79
+ projects: 'List and manage registered projects (--prune, remove <path>)',
79
80
  plugin: 'Manage plugin packs (install, remove, update, clean, list, status)',
80
81
  sync: 'Sync config to/from GitHub Gist (--export, --push, --pull, --import)',
81
82
  'cursor-rules': 'Generate .cursorrules for Cursor IDE (legacy)',
@@ -425,6 +426,7 @@ function handleStatus(_args) {
425
426
  * @param {string[]} args
426
427
  */
427
428
  function handleUpdate(args) {
429
+ const isLocal = args.includes('--local');
428
430
  const statePath = path.join(process.env.HOME, '.ai-toolkit', 'state.json');
429
431
  let stateArgs = [];
430
432
 
@@ -447,6 +449,27 @@ function handleUpdate(args) {
447
449
 
448
450
  // User-provided args override state-derived args
449
451
  run(scriptPath('install.py'), [...stateArgs, ...args]);
452
+
453
+ // After global update (not --local), propagate to all registered projects
454
+ if (!isLocal) {
455
+ const registryPath = path.join(process.env.HOME, '.ai-toolkit', 'projects.json');
456
+ if (fs.existsSync(registryPath)) {
457
+ try {
458
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
459
+ const projects = registry.projects || [];
460
+ if (projects.length > 0) {
461
+ console.log('');
462
+ console.log('## Updating registered projects');
463
+ console.log('');
464
+ // Pass through --skip, --refresh-base flags to project updates
465
+ const passthrough = args.filter(a => a.startsWith('--skip') || a === '--refresh-base');
466
+ run(scriptPath('update_projects.py'), passthrough);
467
+ }
468
+ } catch (_err) {
469
+ // Registry corrupt -- skip project updates
470
+ }
471
+ }
472
+ }
450
473
  }
451
474
 
452
475
  /** @type {Record<string, (args: string[]) => void>} */
@@ -459,6 +482,7 @@ const SPECIAL_HANDLERS = {
459
482
  'sync': handleSync,
460
483
  'mcp': handleMcp,
461
484
  'config': handleConfig,
485
+ 'projects': (args) => run(scriptPath('projects_cli.py'), args),
462
486
  'plugin': (args) => run(scriptPath('plugin.py'), args),
463
487
  'remove-rule': handleRemoveRule,
464
488
  'add-rule': handleAddRule,
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.8.0",
2
+ "version": "1.9.0",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Professional-grade AI coding toolkit: 92 skills, 44 agents, multi-platform support (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo Code, Aider, Augment, Google Antigravity), machine-enforced safety constitution, persona presets, skill security auditor, expanded lifecycle hooks, 11 plugin packs, and benchmark tooling.",
5
5
  "keywords": [
6
6
  "claude",
@@ -57,6 +57,7 @@ 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
60
61
 
61
62
  # Config inheritance (extends system)
62
63
  from config_resolver import (
@@ -704,6 +705,19 @@ def main() -> None:
704
705
  extends_info=extends_info,
705
706
  )
706
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
+
707
721
  print_summary(local=local)
708
722
 
709
723
 
@@ -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()
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """Update all registered projects in parallel.
3
+
4
+ Reads ~/.ai-toolkit/projects.json and runs install.py --local in each
5
+ project directory concurrently using a thread pool.
6
+
7
+ Stdlib-only — no external dependencies.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from concurrent.futures import ThreadPoolExecutor, as_completed
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ from install_steps.project_registry import get_active_projects, prune_stale
21
+
22
+
23
+ def _update_project(project: dict[str, Any], install_script: str, extra_args: list[str]) -> dict:
24
+ """Run install --local in a single project. Returns result dict."""
25
+ project_path = project["path"]
26
+ start = time.monotonic()
27
+
28
+ try:
29
+ proc = subprocess.run(
30
+ ["python3", install_script, "--local"] + extra_args,
31
+ cwd=project_path,
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=120,
35
+ )
36
+ elapsed = time.monotonic() - start
37
+ return {
38
+ "path": project_path,
39
+ "profile": project.get("profile", ""),
40
+ "extends": project.get("extends", ""),
41
+ "success": proc.returncode == 0,
42
+ "elapsed": round(elapsed, 1),
43
+ "output": proc.stdout,
44
+ "error": proc.stderr if proc.returncode != 0 else "",
45
+ }
46
+ except subprocess.TimeoutExpired:
47
+ return {
48
+ "path": project_path,
49
+ "success": False,
50
+ "elapsed": 120.0,
51
+ "output": "",
52
+ "error": "Timed out after 120s",
53
+ }
54
+ except OSError as e:
55
+ return {
56
+ "path": project_path,
57
+ "success": False,
58
+ "elapsed": 0,
59
+ "output": "",
60
+ "error": str(e),
61
+ }
62
+
63
+
64
+ def main() -> None:
65
+ """Update all registered projects."""
66
+ # Parse args
67
+ verbose = "--verbose" in sys.argv or "-v" in sys.argv
68
+ json_output = "--json" in sys.argv
69
+ extra_args = [a for a in sys.argv[1:] if a not in ("--verbose", "-v", "--json")]
70
+
71
+ # Prune stale projects first
72
+ pruned = prune_stale()
73
+ if pruned and not json_output:
74
+ for p in pruned:
75
+ print(f" Pruned stale project: {p}")
76
+
77
+ # Get active projects
78
+ projects = get_active_projects()
79
+
80
+ if not projects:
81
+ if json_output:
82
+ print(json.dumps({"projects": [], "summary": "No registered projects"}))
83
+ else:
84
+ print(" No registered projects.")
85
+ print(" Run 'ai-toolkit install --local' in a project to register it.")
86
+ sys.exit(0)
87
+
88
+ install_script = str(Path(__file__).resolve().parent / "install.py")
89
+
90
+ if not json_output:
91
+ print(f" Updating {len(projects)} registered project(s)...")
92
+ print()
93
+
94
+ # Run in parallel (max 8 workers — don't overwhelm the system)
95
+ max_workers = min(len(projects), 8)
96
+ results: list[dict] = []
97
+
98
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
99
+ futures = {
100
+ pool.submit(_update_project, p, install_script, extra_args): p
101
+ for p in projects
102
+ }
103
+ for future in as_completed(futures):
104
+ result = future.result()
105
+ results.append(result)
106
+
107
+ if not json_output:
108
+ status = "✓" if result["success"] else "✗"
109
+ path_short = result["path"].replace(str(Path.home()), "~")
110
+ extends_info = f" (extends: {result.get('extends', '')})" if result.get("extends") else ""
111
+ print(f" {status} {path_short}{extends_info} ({result['elapsed']}s)")
112
+
113
+ if verbose and result["output"]:
114
+ for line in result["output"].strip().split("\n"):
115
+ print(f" {line}")
116
+
117
+ if result.get("error"):
118
+ for line in result["error"].strip().split("\n"):
119
+ print(f" ERROR: {line}")
120
+
121
+ # Summary
122
+ passed = sum(1 for r in results if r["success"])
123
+ failed = len(results) - passed
124
+
125
+ if json_output:
126
+ print(json.dumps({
127
+ "projects": results,
128
+ "total": len(results),
129
+ "passed": passed,
130
+ "failed": failed,
131
+ }, indent=2))
132
+ else:
133
+ print()
134
+ print(f" Updated: {passed}/{len(results)} projects" +
135
+ (f" ({failed} failed)" if failed else ""))
136
+
137
+ sys.exit(1 if failed else 0)
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()