@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 +15 -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 +24 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/install.py +14 -0
- package/scripts/install_steps/project_registry.py +142 -0
- package/scripts/projects_cli.py +110 -0
- package/scripts/update_projects.py +141 -0
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)
|
|
7
7
|
[](app/skills/)
|
|
8
8
|
[](app/agents/)
|
|
9
|
-
[](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 |
|
|
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 |
|
|
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,
|
|
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
|
|
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.
|
|
4
|
+
"version": "1.9.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "SoftSpark",
|
|
7
7
|
"url": "https://github.com/softspark"
|
package/app/ARCHITECTURE.md
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/scripts/install.py
CHANGED
|
@@ -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()
|