@softspark/ai-toolkit 1.8.0 → 2.0.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 +47 -13
- package/README.md +69 -17
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +7 -1
- package/app/hooks/governance-capture.sh +1 -1
- package/app/hooks/pre-compact-save.sh +1 -1
- package/app/hooks/session-context.sh +1 -1
- package/app/hooks/track-usage.sh +2 -2
- package/app/hooks.json +20 -20
- package/app/plugins/memory-pack/README.md +1 -1
- package/app/plugins/memory-pack/hooks/observation-capture.sh +2 -2
- package/app/plugins/memory-pack/hooks/session-summary.sh +1 -1
- package/app/plugins/memory-pack/scripts/init_db.py +2 -2
- package/app/plugins/memory-pack/skills/mem-search/SKILL.md +3 -3
- package/app/skills/hook-creator/SKILL.md +2 -2
- package/app/skills/mem-search/SKILL.md +3 -3
- package/app/skills/repeat/SKILL.md +1 -1
- package/bin/ai-toolkit.js +32 -3
- package/kb/history/completed/enterprise-config-inheritance-plan-20260412.md +32 -34
- package/kb/history/completed/offline-slm-profile-plan-20260411.md +12 -12
- package/kb/planning/cloud-security-pack-plan.md +3 -3
- package/kb/procedures/maintenance-sop.md +3 -3
- package/kb/reference/architecture-overview.md +5 -5
- package/kb/reference/benchmark-config.md +1 -1
- package/kb/reference/competitive-features-implementation.md +2 -2
- package/kb/reference/enterprise-config-guide.md +9 -9
- package/kb/reference/extension-api.md +1 -1
- package/kb/reference/global-install-model.md +2 -2
- package/kb/reference/hooks-catalog.md +30 -30
- package/kb/reference/integrations.md +3 -3
- package/kb/reference/manifest-install.md +4 -4
- package/kb/reference/plugin-pack-conventions.md +5 -5
- package/kb/reference/stats.md +3 -3
- package/kb/reference/sync.md +3 -3
- package/llms-full.txt +118 -899
- package/llms.txt +0 -1
- package/manifest.json +4 -4
- package/package.json +1 -1
- package/scripts/add_rule.py +3 -2
- package/scripts/benchmark_config.py +3 -1
- package/scripts/compile_slm.py +9 -7
- package/scripts/config_cli.py +7 -7
- package/scripts/config_lock.py +12 -4
- package/scripts/config_merger.py +1 -1
- package/scripts/config_resolver.py +20 -7
- package/scripts/config_scaffold.py +4 -4
- package/scripts/config_validator.py +3 -3
- package/scripts/dir_rules_shared.py +1 -1
- package/scripts/doctor.py +3 -2
- package/scripts/install.py +25 -6
- package/scripts/install_git_hooks.py +1 -1
- package/scripts/install_steps/ai_tools.py +4 -4
- package/scripts/install_steps/hooks.py +1 -1
- package/scripts/install_steps/install_state.py +8 -6
- package/scripts/install_steps/project_registry.py +142 -0
- package/scripts/migrate.py +199 -0
- package/scripts/paths.py +50 -0
- package/scripts/plugin.py +8 -6
- package/scripts/projects_cli.py +110 -0
- package/scripts/remove_rule.py +5 -4
- package/scripts/stats.py +3 -2
- package/scripts/sync.py +3 -2
- package/scripts/update_projects.py +141 -0
- package/scripts/version_check.py +5 -2
- package/kb/planning/local-dashboard-plan.md +0 -773
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Project registry — tracks which directories have ai-toolkit installed locally.
|
|
2
|
+
|
|
3
|
+
Stores registry in ~/.softspark/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 sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
18
|
+
from paths import PROJECTS_FILE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _registry_path() -> Path:
|
|
22
|
+
"""Return the canonical path to projects.json."""
|
|
23
|
+
return PROJECTS_FILE
|
|
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,199 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Migrate ai-toolkit data from ~/.ai-toolkit to ~/.softspark/ai-toolkit.
|
|
3
|
+
|
|
4
|
+
Called automatically by install.py and ai-toolkit.js on first run.
|
|
5
|
+
Can also be invoked directly: python3 scripts/migrate.py [--dry-run]
|
|
6
|
+
|
|
7
|
+
Migration steps:
|
|
8
|
+
1. Detect legacy ~/.ai-toolkit directory
|
|
9
|
+
2. Create ~/.softspark/ai-toolkit/
|
|
10
|
+
3. Move all contents (state, hooks, rules, sessions, etc.)
|
|
11
|
+
4. Leave ~/.ai-toolkit/.migrated marker with pointer to new location
|
|
12
|
+
5. Migrate per-project .ai-toolkit.json → .softspark-toolkit.json (via registry)
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
0 Migration succeeded or nothing to migrate
|
|
16
|
+
1 Migration failed
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
+
from paths import (
|
|
29
|
+
LEGACY_DATA_DIR,
|
|
30
|
+
LEGACY_PROJECT_CONFIG,
|
|
31
|
+
LEGACY_PROJECT_LOCK,
|
|
32
|
+
PROJECT_CONFIG_FILENAME,
|
|
33
|
+
PROJECT_LOCK_FILENAME,
|
|
34
|
+
SOFTSPARK_DIR,
|
|
35
|
+
TOOLKIT_DATA_DIR,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
MIGRATED_MARKER = LEGACY_DATA_DIR / ".migrated"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def needs_migration() -> bool:
|
|
43
|
+
"""Check if legacy directory exists and hasn't been migrated yet."""
|
|
44
|
+
if not LEGACY_DATA_DIR.is_dir():
|
|
45
|
+
return False
|
|
46
|
+
if MIGRATED_MARKER.is_file():
|
|
47
|
+
return False
|
|
48
|
+
# Don't migrate if AI_TOOLKIT_HOME is set (custom setup)
|
|
49
|
+
if os.environ.get("AI_TOOLKIT_HOME"):
|
|
50
|
+
return False
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def migrate_home_directory(dry_run: bool = False) -> bool:
|
|
55
|
+
"""Migrate ~/.ai-toolkit → ~/.softspark/ai-toolkit.
|
|
56
|
+
|
|
57
|
+
Returns True if migration was performed, False if skipped.
|
|
58
|
+
"""
|
|
59
|
+
if not needs_migration():
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
print()
|
|
63
|
+
print("## Migrating to new directory structure")
|
|
64
|
+
print(f" {LEGACY_DATA_DIR} → {TOOLKIT_DATA_DIR}")
|
|
65
|
+
print()
|
|
66
|
+
|
|
67
|
+
if dry_run:
|
|
68
|
+
print(" (dry-run: no changes made)")
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
# Create parent ~/.softspark/
|
|
72
|
+
SOFTSPARK_DIR.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
if TOOLKIT_DATA_DIR.exists():
|
|
75
|
+
# New dir already exists (partial migration?) — merge carefully
|
|
76
|
+
_merge_directories(LEGACY_DATA_DIR, TOOLKIT_DATA_DIR)
|
|
77
|
+
else:
|
|
78
|
+
# Clean move
|
|
79
|
+
shutil.move(str(LEGACY_DATA_DIR), str(TOOLKIT_DATA_DIR))
|
|
80
|
+
# Recreate legacy dir for marker
|
|
81
|
+
LEGACY_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
# Write migration marker
|
|
84
|
+
_write_marker()
|
|
85
|
+
|
|
86
|
+
print(f" Migrated: {TOOLKIT_DATA_DIR}")
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _merge_directories(src: Path, dst: Path) -> None:
|
|
91
|
+
"""Merge src into dst, preferring src files (newer)."""
|
|
92
|
+
for item in src.iterdir():
|
|
93
|
+
if item.name == ".migrated":
|
|
94
|
+
continue
|
|
95
|
+
dest_item = dst / item.name
|
|
96
|
+
if item.is_dir():
|
|
97
|
+
if dest_item.is_dir():
|
|
98
|
+
_merge_directories(item, dest_item)
|
|
99
|
+
else:
|
|
100
|
+
shutil.move(str(item), str(dest_item))
|
|
101
|
+
else:
|
|
102
|
+
# Overwrite with source (legacy has the latest data)
|
|
103
|
+
shutil.move(str(item), str(dest_item))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_marker() -> None:
|
|
107
|
+
"""Write .migrated marker pointing to new location."""
|
|
108
|
+
LEGACY_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
110
|
+
marker_data = {
|
|
111
|
+
"migrated_to": str(TOOLKIT_DATA_DIR),
|
|
112
|
+
"migrated_at": now,
|
|
113
|
+
"message": "ai-toolkit data has moved to ~/.softspark/ai-toolkit/. "
|
|
114
|
+
"This directory is kept as a marker. Safe to delete.",
|
|
115
|
+
}
|
|
116
|
+
with open(MIGRATED_MARKER, "w", encoding="utf-8") as f:
|
|
117
|
+
json.dump(marker_data, f, indent=2)
|
|
118
|
+
f.write("\n")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def migrate_project_configs(dry_run: bool = False) -> int:
|
|
122
|
+
"""Rename .ai-toolkit.json → .softspark-toolkit.json in registered projects.
|
|
123
|
+
|
|
124
|
+
Returns the number of projects migrated.
|
|
125
|
+
"""
|
|
126
|
+
registry_file = TOOLKIT_DATA_DIR / "projects.json"
|
|
127
|
+
if not registry_file.is_file():
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
with open(registry_file, encoding="utf-8") as f:
|
|
132
|
+
data = json.load(f)
|
|
133
|
+
projects = data.get("projects", [])
|
|
134
|
+
except (json.JSONDecodeError, OSError):
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
count = 0
|
|
138
|
+
for project in projects:
|
|
139
|
+
project_path = Path(project.get("path", ""))
|
|
140
|
+
if not project_path.is_dir():
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Migrate .ai-toolkit.json → .softspark-toolkit.json
|
|
144
|
+
old_config = project_path / LEGACY_PROJECT_CONFIG
|
|
145
|
+
new_config = project_path / PROJECT_CONFIG_FILENAME
|
|
146
|
+
if old_config.is_file() and not new_config.is_file():
|
|
147
|
+
if dry_run:
|
|
148
|
+
print(f" Would rename: {old_config} → {new_config}")
|
|
149
|
+
else:
|
|
150
|
+
old_config.rename(new_config)
|
|
151
|
+
print(f" Renamed: {old_config.name} → {new_config.name} in {project_path}")
|
|
152
|
+
count += 1
|
|
153
|
+
|
|
154
|
+
# Migrate .ai-toolkit.lock.json → .softspark-toolkit.lock.json
|
|
155
|
+
old_lock = project_path / LEGACY_PROJECT_LOCK
|
|
156
|
+
new_lock = project_path / PROJECT_LOCK_FILENAME
|
|
157
|
+
if old_lock.is_file() and not new_lock.is_file():
|
|
158
|
+
if dry_run:
|
|
159
|
+
print(f" Would rename: {old_lock} → {new_lock}")
|
|
160
|
+
else:
|
|
161
|
+
old_lock.rename(new_lock)
|
|
162
|
+
|
|
163
|
+
return count
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def run_full_migration(dry_run: bool = False) -> bool:
|
|
167
|
+
"""Run complete migration: home directory + project configs.
|
|
168
|
+
|
|
169
|
+
Returns True if any migration was performed.
|
|
170
|
+
"""
|
|
171
|
+
migrated = migrate_home_directory(dry_run=dry_run)
|
|
172
|
+
|
|
173
|
+
if migrated and not dry_run:
|
|
174
|
+
count = migrate_project_configs(dry_run=dry_run)
|
|
175
|
+
if count > 0:
|
|
176
|
+
print(f" Migrated {count} project config(s)")
|
|
177
|
+
|
|
178
|
+
return migrated
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main() -> None:
|
|
182
|
+
dry_run = "--dry-run" in sys.argv
|
|
183
|
+
|
|
184
|
+
if not needs_migration():
|
|
185
|
+
print("Nothing to migrate (no legacy ~/.ai-toolkit found or already migrated).")
|
|
186
|
+
sys.exit(0)
|
|
187
|
+
|
|
188
|
+
success = run_full_migration(dry_run=dry_run)
|
|
189
|
+
|
|
190
|
+
if success:
|
|
191
|
+
print()
|
|
192
|
+
print("Migration complete. The old ~/.ai-toolkit/ directory contains only a")
|
|
193
|
+
print("migration marker and can be safely deleted.")
|
|
194
|
+
elif not dry_run:
|
|
195
|
+
print("Migration skipped.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
package/scripts/paths.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Centralized path constants for ai-toolkit.
|
|
2
|
+
|
|
3
|
+
All scripts MUST import paths from here — never hardcode ~/.ai-toolkit or
|
|
4
|
+
~/.softspark/ai-toolkit directly.
|
|
5
|
+
|
|
6
|
+
Supports override via AI_TOOLKIT_HOME env var for testing and custom setups.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Canonical paths
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
SOFTSPARK_DIR = Path(os.environ.get("SOFTSPARK_HOME", Path.home() / ".softspark"))
|
|
18
|
+
|
|
19
|
+
TOOLKIT_DATA_DIR = Path(
|
|
20
|
+
os.environ.get("AI_TOOLKIT_HOME", SOFTSPARK_DIR / "ai-toolkit")
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Legacy path (pre-v2.0) — used only by migration detection
|
|
24
|
+
LEGACY_DATA_DIR = Path.home() / ".ai-toolkit"
|
|
25
|
+
|
|
26
|
+
# Sub-directories under TOOLKIT_DATA_DIR
|
|
27
|
+
HOOKS_DIR = TOOLKIT_DATA_DIR / "hooks"
|
|
28
|
+
RULES_DIR = TOOLKIT_DATA_DIR / "rules"
|
|
29
|
+
SESSIONS_DIR = TOOLKIT_DATA_DIR / "sessions"
|
|
30
|
+
COMPACTIONS_DIR = TOOLKIT_DATA_DIR / "compactions"
|
|
31
|
+
CONFIG_CACHE_DIR = TOOLKIT_DATA_DIR / "config-cache"
|
|
32
|
+
COMPILED_DIR = TOOLKIT_DATA_DIR / "compiled"
|
|
33
|
+
|
|
34
|
+
# Files under TOOLKIT_DATA_DIR
|
|
35
|
+
STATE_FILE = TOOLKIT_DATA_DIR / "state.json"
|
|
36
|
+
PROJECTS_FILE = TOOLKIT_DATA_DIR / "projects.json"
|
|
37
|
+
STATS_FILE = TOOLKIT_DATA_DIR / "stats.json"
|
|
38
|
+
GOVERNANCE_LOG = TOOLKIT_DATA_DIR / "governance.log"
|
|
39
|
+
VERSION_CHECK_FILE = TOOLKIT_DATA_DIR / "version-check.json"
|
|
40
|
+
|
|
41
|
+
# Per-project config filenames
|
|
42
|
+
PROJECT_CONFIG_FILENAME = ".softspark-toolkit.json"
|
|
43
|
+
PROJECT_LOCK_FILENAME = ".softspark-toolkit.lock.json"
|
|
44
|
+
|
|
45
|
+
# Legacy per-project config filenames (for migration)
|
|
46
|
+
LEGACY_PROJECT_CONFIG = ".ai-toolkit.json"
|
|
47
|
+
LEGACY_PROJECT_LOCK = ".ai-toolkit.lock.json"
|
|
48
|
+
|
|
49
|
+
# Base config filename (published in npm packages — unchanged)
|
|
50
|
+
BASE_CONFIG_FILENAME = "ai-toolkit.config.json"
|
package/scripts/plugin.py
CHANGED
|
@@ -35,10 +35,12 @@ from _common import toolkit_dir, app_dir
|
|
|
35
35
|
from plugin_schema import resolve_hook_event, validate_manifest, validate_references
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
from paths import TOOLKIT_DATA_DIR, HOOKS_DIR as _HOOKS_DIR
|
|
39
|
+
|
|
38
40
|
PLUGINS_DIR = app_dir / "plugins"
|
|
39
41
|
CLAUDE_DIR = Path.home() / ".claude"
|
|
40
|
-
HOOKS_DIR =
|
|
41
|
-
PLUGINS_STATE_FILE =
|
|
42
|
+
HOOKS_DIR = _HOOKS_DIR
|
|
43
|
+
PLUGINS_STATE_FILE = TOOLKIT_DATA_DIR / "plugins.json"
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
# ---------------------------------------------------------------------------
|
|
@@ -156,7 +158,7 @@ def _copy_scripts(name: str, pack_dir: Path, installed_items: list[str]) -> None
|
|
|
156
158
|
plugin_scripts_dir = pack_dir / "scripts"
|
|
157
159
|
if not plugin_scripts_dir.is_dir():
|
|
158
160
|
return
|
|
159
|
-
scripts_dest =
|
|
161
|
+
scripts_dest = TOOLKIT_DATA_DIR / "plugin-scripts" / name
|
|
160
162
|
scripts_dest.mkdir(parents=True, exist_ok=True)
|
|
161
163
|
for script_file in sorted(plugin_scripts_dir.iterdir()):
|
|
162
164
|
if script_file.name.startswith("__"):
|
|
@@ -282,7 +284,7 @@ def remove_pack(name: str) -> bool:
|
|
|
282
284
|
|
|
283
285
|
print(f" Removing: {name}")
|
|
284
286
|
|
|
285
|
-
# 1. Remove plugin hooks from ~/.ai-toolkit/hooks/
|
|
287
|
+
# 1. Remove plugin hooks from ~/.softspark/ai-toolkit/hooks/
|
|
286
288
|
removed = 0
|
|
287
289
|
for hook in HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
|
|
288
290
|
hook.unlink()
|
|
@@ -290,7 +292,7 @@ def remove_pack(name: str) -> bool:
|
|
|
290
292
|
removed += 1
|
|
291
293
|
|
|
292
294
|
# 2. Remove plugin scripts
|
|
293
|
-
scripts_dir =
|
|
295
|
+
scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
|
|
294
296
|
if scripts_dir.is_dir():
|
|
295
297
|
shutil.rmtree(scripts_dir)
|
|
296
298
|
print(f" Removed scripts: {scripts_dir}")
|
|
@@ -358,7 +360,7 @@ def update_pack(name: str) -> bool:
|
|
|
358
360
|
# Clean
|
|
359
361
|
# ---------------------------------------------------------------------------
|
|
360
362
|
|
|
361
|
-
MEMORY_DB =
|
|
363
|
+
MEMORY_DB = TOOLKIT_DATA_DIR / "memory.db"
|
|
362
364
|
|
|
363
365
|
# Map plugin names to their clean logic
|
|
364
366
|
CLEANABLE_PLUGINS = {"memory-pack"}
|
|
@@ -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()
|
package/scripts/remove_rule.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""remove-rule -- Unregister a rule (opposite of add-rule).
|
|
3
3
|
|
|
4
|
-
Removes the rule file from ~/.ai-toolkit/rules/ (so it is no longer
|
|
4
|
+
Removes the rule file from ~/.softspark/ai-toolkit/rules/ (so it is no longer
|
|
5
5
|
re-applied on future 'ai-toolkit install' runs) AND strips its injected
|
|
6
6
|
block from the target CLAUDE.md.
|
|
7
7
|
|
|
@@ -29,11 +29,12 @@ def main() -> None:
|
|
|
29
29
|
|
|
30
30
|
rule_name = sys.argv[1]
|
|
31
31
|
target_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path.home()
|
|
32
|
-
|
|
32
|
+
from paths import RULES_DIR
|
|
33
|
+
rules_dir = RULES_DIR
|
|
33
34
|
|
|
34
35
|
removed = 0
|
|
35
36
|
|
|
36
|
-
# 1. Unregister from ~/.ai-toolkit/rules/
|
|
37
|
+
# 1. Unregister from ~/.softspark/ai-toolkit/rules/
|
|
37
38
|
rule_file = rules_dir / f"{rule_name}.md"
|
|
38
39
|
if rule_file.is_file():
|
|
39
40
|
rule_file.unlink()
|
|
@@ -51,7 +52,7 @@ def main() -> None:
|
|
|
51
52
|
if removed == 0:
|
|
52
53
|
print()
|
|
53
54
|
print("Nothing to unregister. To list registered rules:")
|
|
54
|
-
print(" ls
|
|
55
|
+
print(f" ls {rules_dir}/")
|
|
55
56
|
|
|
56
57
|
|
|
57
58
|
if __name__ == "__main__":
|
package/scripts/stats.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""ai-toolkit stats -- Show skill usage statistics.
|
|
3
3
|
|
|
4
|
-
Reads ~/.ai-toolkit/stats.json (populated by track-usage.sh hook)
|
|
4
|
+
Reads ~/.softspark/ai-toolkit/stats.json (populated by track-usage.sh hook)
|
|
5
5
|
and displays a sorted table of skill invocations.
|
|
6
6
|
|
|
7
7
|
Options:
|
|
@@ -15,8 +15,9 @@ import sys
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
17
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
18
|
+
from paths import STATS_FILE as _STATS_FILE
|
|
18
19
|
|
|
19
|
-
STATS_FILE =
|
|
20
|
+
STATS_FILE = _STATS_FILE
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def main() -> None:
|
package/scripts/sync.py
CHANGED
|
@@ -21,9 +21,10 @@ from pathlib import Path
|
|
|
21
21
|
|
|
22
22
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
23
|
from _common import toolkit_dir
|
|
24
|
+
from paths import TOOLKIT_DATA_DIR, RULES_DIR as _RULES_DIR
|
|
24
25
|
|
|
25
|
-
CONFIG_DIR =
|
|
26
|
-
RULES_DIR =
|
|
26
|
+
CONFIG_DIR = TOOLKIT_DATA_DIR
|
|
27
|
+
RULES_DIR = _RULES_DIR
|
|
27
28
|
GIST_ID_FILE = CONFIG_DIR / ".gist-id"
|
|
28
29
|
|
|
29
30
|
|