@softspark/ai-toolkit 4.29.2 → 4.30.2
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 +82 -0
- package/README.md +44 -18
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/app/mcp-templates/README.md +7 -2
- package/app/mcp-templates/rag-mcp-legal.json +11 -0
- package/app/mcp-templates/rag-mcp.json +11 -0
- package/app/surface.json +1 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
- package/bin/ai-toolkit.js +8 -0
- package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
- package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
- package/kb/procedures/ecosystem-sync-sop.md +7 -5
- package/kb/procedures/maintenance-sop.md +1 -1
- package/kb/procedures/release-verification-sop.md +35 -5
- package/kb/reference/architecture-overview.md +24 -5
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/dsh-compatibility.md +183 -0
- package/kb/reference/manifest-install.md +112 -5
- package/kb/reference/mcp-templates.md +11 -4
- package/kb/reference/plugin-pack-conventions.md +35 -18
- package/kb/reference/supported-tools-registry.md +30 -6
- package/llms-full.txt +1110 -50
- package/llms.txt +3 -0
- package/manifest.json +2 -2
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +673 -34
- package/scripts/config_resolver.py +80 -14
- package/scripts/doctor.py +98 -20
- package/scripts/ecosystem_tools.json +51 -1
- package/scripts/generate_codex_skills.py +22 -20
- package/scripts/install.py +30 -13
- package/scripts/install_steps/ai_tools.py +97 -33
- package/scripts/install_steps/dsh.py +5063 -0
- package/scripts/install_steps/install_state.py +1645 -57
- package/scripts/mcp_editors.py +5 -2
- package/scripts/plugin.py +2495 -163
- package/scripts/plugin_mcp.py +279 -0
- package/scripts/plugin_rules.py +389 -0
- package/scripts/plugin_schema.py +139 -23
- package/scripts/uninstall.py +47 -4
- package/scripts/validate.py +421 -0
package/scripts/plugin_schema.py
CHANGED
|
@@ -9,8 +9,11 @@ Used by both validate.py and plugin.py.
|
|
|
9
9
|
|
|
10
10
|
Stdlib-only.
|
|
11
11
|
"""
|
|
12
|
+
|
|
12
13
|
from __future__ import annotations
|
|
13
14
|
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
14
17
|
from pathlib import Path
|
|
15
18
|
|
|
16
19
|
|
|
@@ -28,27 +31,60 @@ REQUIRED_FIELDS = (
|
|
|
28
31
|
VALID_STATUSES = frozenset({"stable", "experimental", "deprecated"})
|
|
29
32
|
|
|
30
33
|
# Valid plugin types
|
|
31
|
-
VALID_TYPES = frozenset(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
VALID_TYPES = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"behavioral",
|
|
37
|
+
"language",
|
|
38
|
+
"domain",
|
|
39
|
+
"integration",
|
|
40
|
+
"plugin-pack",
|
|
41
|
+
"policy-pack",
|
|
42
|
+
"hook-pack",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
40
45
|
|
|
41
46
|
# Valid hook event names (must match validate.py VALID_HOOK_EVENTS)
|
|
42
|
-
VALID_HOOK_EVENTS = frozenset(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
VALID_HOOK_EVENTS = frozenset(
|
|
48
|
+
{
|
|
49
|
+
"SessionStart",
|
|
50
|
+
"SessionEnd",
|
|
51
|
+
"UserPromptSubmit",
|
|
52
|
+
"Notification",
|
|
53
|
+
"MessageDisplay",
|
|
54
|
+
"PreToolUse",
|
|
55
|
+
"PostToolUse",
|
|
56
|
+
"PostToolUseFailure",
|
|
57
|
+
"PostToolBatch",
|
|
58
|
+
"Stop",
|
|
59
|
+
"StopFailure",
|
|
60
|
+
"UserPromptExpansion",
|
|
61
|
+
"SubagentStart",
|
|
62
|
+
"SubagentStop",
|
|
63
|
+
"PreCompact",
|
|
64
|
+
"PostCompact",
|
|
65
|
+
"PermissionRequest",
|
|
66
|
+
"PermissionDenied",
|
|
67
|
+
"Elicitation",
|
|
68
|
+
"ElicitationResult",
|
|
69
|
+
"TaskCreated",
|
|
70
|
+
"TaskCompleted",
|
|
71
|
+
"TeammateIdle",
|
|
72
|
+
"WorktreeCreate",
|
|
73
|
+
"WorktreeRemove",
|
|
74
|
+
"CwdChanged",
|
|
75
|
+
"FileChanged",
|
|
76
|
+
"ConfigChange",
|
|
77
|
+
"DirectoryAdded",
|
|
78
|
+
"Setup",
|
|
79
|
+
"InstructionsLoaded",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
MCP_REFERENCE_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
84
|
+
LOCAL_ENDPOINT_MARKERS = ("localhost", "127.0.0.1", "[::1]")
|
|
85
|
+
BUILTIN_MCP_TEMPLATES_DIR = (
|
|
86
|
+
Path(__file__).resolve().parent.parent / "app" / "mcp-templates"
|
|
87
|
+
)
|
|
52
88
|
|
|
53
89
|
|
|
54
90
|
def _validate_requires(data: dict) -> list[str]:
|
|
@@ -81,11 +117,18 @@ def validate_manifest(data: dict, pack_dir: Path | None = None) -> list[str]:
|
|
|
81
117
|
if field not in data or not data[field]:
|
|
82
118
|
errors.append(f"Missing required field: {field}")
|
|
83
119
|
errors.extend(_validate_requires(data))
|
|
120
|
+
name = data.get("name")
|
|
121
|
+
if name and (
|
|
122
|
+
not isinstance(name, str) or MCP_REFERENCE_PATTERN.fullmatch(name) is None
|
|
123
|
+
):
|
|
124
|
+
errors.append("name must be a safe lowercase-hyphen identifier")
|
|
84
125
|
|
|
85
126
|
# Validate status
|
|
86
127
|
status = data.get("status", "")
|
|
87
128
|
if status and status not in VALID_STATUSES:
|
|
88
|
-
errors.append(
|
|
129
|
+
errors.append(
|
|
130
|
+
f"Invalid status '{status}' (valid: {', '.join(sorted(VALID_STATUSES))})"
|
|
131
|
+
)
|
|
89
132
|
|
|
90
133
|
# Validate type
|
|
91
134
|
plugin_type = data.get("type", "")
|
|
@@ -101,20 +144,41 @@ def validate_manifest(data: dict, pack_dir: Path | None = None) -> list[str]:
|
|
|
101
144
|
elif not isinstance(includes, dict):
|
|
102
145
|
errors.append("'includes' must be a dictionary")
|
|
103
146
|
else:
|
|
104
|
-
for key in ("agents", "skills", "rules", "hooks"):
|
|
147
|
+
for key in ("agents", "skills", "rules", "hooks", "mcp"):
|
|
105
148
|
val = includes.get(key, [])
|
|
106
149
|
if not isinstance(val, list):
|
|
107
150
|
errors.append(f"includes.{key} must be a list")
|
|
108
151
|
|
|
152
|
+
mcp_references = includes.get("mcp", [])
|
|
153
|
+
if isinstance(mcp_references, list):
|
|
154
|
+
invalid_mcp_name = False
|
|
155
|
+
for reference in mcp_references:
|
|
156
|
+
if (
|
|
157
|
+
not isinstance(reference, str)
|
|
158
|
+
or MCP_REFERENCE_PATTERN.fullmatch(reference) is None
|
|
159
|
+
):
|
|
160
|
+
invalid_mcp_name = True
|
|
161
|
+
continue
|
|
162
|
+
if pack_dir is not None:
|
|
163
|
+
errors.extend(_validate_mcp_reference(reference, pack_dir))
|
|
164
|
+
if invalid_mcp_name:
|
|
165
|
+
errors.append(
|
|
166
|
+
"includes.mcp entries must be safe lowercase-hyphen names"
|
|
167
|
+
)
|
|
168
|
+
|
|
109
169
|
# Validate hook_events if present
|
|
110
170
|
hook_events = data.get("hook_events", {})
|
|
111
171
|
if hook_events:
|
|
112
172
|
if not isinstance(hook_events, dict):
|
|
113
|
-
errors.append(
|
|
173
|
+
errors.append(
|
|
174
|
+
"'hook_events' must be a dictionary mapping hook filenames to event names"
|
|
175
|
+
)
|
|
114
176
|
else:
|
|
115
177
|
for hook_file, event in hook_events.items():
|
|
116
178
|
if event not in VALID_HOOK_EVENTS:
|
|
117
|
-
errors.append(
|
|
179
|
+
errors.append(
|
|
180
|
+
f"hook_events['{hook_file}']: invalid event '{event}'"
|
|
181
|
+
)
|
|
118
182
|
|
|
119
183
|
# Validate hook files exist (if pack_dir provided)
|
|
120
184
|
# Hooks can come from the plugin's own hooks/ dir OR from core app/hooks/
|
|
@@ -136,6 +200,58 @@ def validate_manifest(data: dict, pack_dir: Path | None = None) -> list[str]:
|
|
|
136
200
|
return errors
|
|
137
201
|
|
|
138
202
|
|
|
203
|
+
def _validate_mcp_reference(reference: str, pack_dir: Path) -> list[str]:
|
|
204
|
+
candidates = (
|
|
205
|
+
pack_dir / "mcp" / f"{reference}.json",
|
|
206
|
+
BUILTIN_MCP_TEMPLATES_DIR / f"{reference}.json",
|
|
207
|
+
)
|
|
208
|
+
template_path = next((path for path in candidates if path.is_file()), None)
|
|
209
|
+
if template_path is None:
|
|
210
|
+
return [f"MCP template not found: {reference}"]
|
|
211
|
+
if template_path.is_symlink():
|
|
212
|
+
return [f"MCP template must not be symlinked: {reference}"]
|
|
213
|
+
try:
|
|
214
|
+
template = json.loads(template_path.read_text(encoding="utf-8"))
|
|
215
|
+
except (json.JSONDecodeError, OSError):
|
|
216
|
+
return [f"Invalid MCP template JSON: {reference}"]
|
|
217
|
+
if not isinstance(template, dict) or template.get("name") != reference:
|
|
218
|
+
return [f"MCP template name mismatch: {reference}"]
|
|
219
|
+
servers = template.get("mcpServers")
|
|
220
|
+
if not isinstance(servers, dict) or not servers:
|
|
221
|
+
return [f"MCP template requires non-empty mcpServers: {reference}"]
|
|
222
|
+
if not all(_is_valid_mcp_server(name, server) for name, server in servers.items()):
|
|
223
|
+
return [f"MCP template has invalid server entries: {reference}"]
|
|
224
|
+
local_remote = any(
|
|
225
|
+
isinstance(server.get("url"), str)
|
|
226
|
+
and any(marker in server["url"].lower() for marker in LOCAL_ENDPOINT_MARKERS)
|
|
227
|
+
for server in servers.values()
|
|
228
|
+
)
|
|
229
|
+
warning = template.get("postInstall", "")
|
|
230
|
+
if local_remote and (
|
|
231
|
+
not isinstance(warning, str) or "unauthenticated" not in warning.lower()
|
|
232
|
+
):
|
|
233
|
+
return [
|
|
234
|
+
"Local HTTP MCP template must warn that access is unauthenticated: "
|
|
235
|
+
f"{reference}"
|
|
236
|
+
]
|
|
237
|
+
return []
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _is_valid_mcp_server(name: object, server: object) -> bool:
|
|
241
|
+
if (
|
|
242
|
+
not isinstance(name, str)
|
|
243
|
+
or MCP_REFERENCE_PATTERN.fullmatch(name) is None
|
|
244
|
+
or not isinstance(server, dict)
|
|
245
|
+
):
|
|
246
|
+
return False
|
|
247
|
+
transports = [key for key in ("command", "url") if key in server]
|
|
248
|
+
return (
|
|
249
|
+
len(transports) == 1
|
|
250
|
+
and isinstance(server[transports[0]], str)
|
|
251
|
+
and bool(server[transports[0]].strip())
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
139
255
|
def validate_references(
|
|
140
256
|
data: dict,
|
|
141
257
|
agents_dir: Path,
|
package/scripts/uninstall.py
CHANGED
|
@@ -37,6 +37,11 @@ from typing import Any
|
|
|
37
37
|
|
|
38
38
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
39
39
|
from _common import app_dir, toolkit_dir
|
|
40
|
+
from codex_skill_adapter import (
|
|
41
|
+
ADAPTED_MARKERS,
|
|
42
|
+
SKILL_SURFACE_OWNERS_MARKER,
|
|
43
|
+
skill_surface_owners,
|
|
44
|
+
)
|
|
40
45
|
from injection import strip_all_sections, strip_section, trim_trailing_blanks
|
|
41
46
|
# Retirement cleanup for the v4.16.x tool-output filter. The runtime package
|
|
42
47
|
# that wrote those files is gone; output_filter_retirement re-states its
|
|
@@ -51,7 +56,6 @@ from output_filter_retirement import (
|
|
|
51
56
|
|
|
52
57
|
|
|
53
58
|
CODEX_AGENT_MARKER = "# ai-toolkit-managed: codex-agent"
|
|
54
|
-
CODEX_ADAPTED_SKILL_MARKER = ".ai-toolkit-codex-adapted"
|
|
55
59
|
CODEX_HOOK_ASSET_MARKER = "# ai-toolkit-managed: codex-hook-script"
|
|
56
60
|
COPILOT_MARKER = "<!-- ai-toolkit-managed: github-copilot -->"
|
|
57
61
|
COPILOT_SKILL_MANIFEST = ".ai-toolkit-managed-files"
|
|
@@ -712,8 +716,11 @@ def _is_codex_agent(path: Path) -> bool:
|
|
|
712
716
|
def _is_codex_skill(path: Path) -> bool:
|
|
713
717
|
if path.is_symlink():
|
|
714
718
|
return _is_toolkit_link(path)
|
|
715
|
-
|
|
716
|
-
|
|
719
|
+
return path.is_dir() and any(
|
|
720
|
+
not (path / marker_name).is_symlink()
|
|
721
|
+
and (path / marker_name).is_file()
|
|
722
|
+
for marker_name in ADAPTED_MARKERS
|
|
723
|
+
)
|
|
717
724
|
|
|
718
725
|
|
|
719
726
|
def _remove_codex_skills(skills_root: Path, trusted_root: Path) -> int:
|
|
@@ -729,7 +736,7 @@ def _remove_codex_skills(skills_root: Path, trusted_root: Path) -> int:
|
|
|
729
736
|
if not _is_codex_skill(skill):
|
|
730
737
|
continue
|
|
731
738
|
for child in sorted(skill.iterdir()):
|
|
732
|
-
if child.name in {"SKILL.md",
|
|
739
|
+
if child.name in {"SKILL.md", *ADAPTED_MARKERS}:
|
|
733
740
|
if not child.is_symlink() and child.is_file():
|
|
734
741
|
_safe_unlink(child, trusted_root)
|
|
735
742
|
continue
|
|
@@ -741,6 +748,25 @@ def _remove_codex_skills(skills_root: Path, trusted_root: Path) -> int:
|
|
|
741
748
|
return removed
|
|
742
749
|
|
|
743
750
|
|
|
751
|
+
def _remove_skill_surface_owners_marker(
|
|
752
|
+
skills_root: Path,
|
|
753
|
+
trusted_root: Path,
|
|
754
|
+
) -> bool:
|
|
755
|
+
"""Remove a valid owner marker after every managed skill is gone."""
|
|
756
|
+
marker = skills_root.parent / SKILL_SURFACE_OWNERS_MARKER
|
|
757
|
+
owners = skill_surface_owners(skills_root)
|
|
758
|
+
if not owners:
|
|
759
|
+
return False
|
|
760
|
+
if skills_root.is_dir() and any(
|
|
761
|
+
_is_codex_skill(skill) for skill in skills_root.iterdir()
|
|
762
|
+
):
|
|
763
|
+
return False
|
|
764
|
+
if skill_surface_owners(skills_root) != owners:
|
|
765
|
+
raise RuntimeError(f"Skill surface ownership changed before removal: {marker}")
|
|
766
|
+
_safe_unlink(marker, trusted_root)
|
|
767
|
+
return True
|
|
768
|
+
|
|
769
|
+
|
|
744
770
|
def _is_codex_core_handler(handler: Any, group: dict[str, Any]) -> bool:
|
|
745
771
|
if group.get("_source") == "ai-toolkit":
|
|
746
772
|
return True
|
|
@@ -838,6 +864,13 @@ def _discover_codex(surface: CodexSurface) -> list[tuple[str, str]]:
|
|
|
838
864
|
count = sum(1 for path in surface.skills_root.iterdir() if _is_codex_skill(path))
|
|
839
865
|
if count:
|
|
840
866
|
found.append((f"Managed: {surface.skills_root} ({count} Codex skills)", "codex-skills"))
|
|
867
|
+
owners = skill_surface_owners(surface.skills_root)
|
|
868
|
+
if owners:
|
|
869
|
+
marker = surface.skills_root.parent / SKILL_SURFACE_OWNERS_MARKER
|
|
870
|
+
found.append((
|
|
871
|
+
f"Managed: {marker} ({', '.join(sorted(owners))})",
|
|
872
|
+
"agent-skill-owners",
|
|
873
|
+
))
|
|
841
874
|
hooks_path = surface.config_root / "hooks.json"
|
|
842
875
|
data = _load_json(hooks_path, "Codex hooks file")
|
|
843
876
|
if data is not None:
|
|
@@ -883,6 +916,11 @@ def _remove_codex(surface: CodexSurface) -> None:
|
|
|
883
916
|
)
|
|
884
917
|
if removed_skills:
|
|
885
918
|
print(f" Removed: {removed_skills} managed Codex skill(s)")
|
|
919
|
+
if _remove_skill_surface_owners_marker(
|
|
920
|
+
surface.skills_root,
|
|
921
|
+
skills_boundary,
|
|
922
|
+
):
|
|
923
|
+
print(" Removed: managed agent-skill owner marker")
|
|
886
924
|
removed_hooks = _remove_codex_hooks(
|
|
887
925
|
surface.config_root / "hooks.json",
|
|
888
926
|
config_boundary,
|
|
@@ -1286,6 +1324,11 @@ def _transaction_specs(
|
|
|
1286
1324
|
(surface.config_root / "hooks.json", False, config_boundary),
|
|
1287
1325
|
(surface.assets_root, True, config_boundary),
|
|
1288
1326
|
(surface.skills_root.parent, False, skills_boundary),
|
|
1327
|
+
(
|
|
1328
|
+
surface.skills_root.parent / SKILL_SURFACE_OWNERS_MARKER,
|
|
1329
|
+
False,
|
|
1330
|
+
skills_boundary,
|
|
1331
|
+
),
|
|
1289
1332
|
(surface.skills_root, True, skills_boundary),
|
|
1290
1333
|
):
|
|
1291
1334
|
add(path, recursive, trusted_root)
|