@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +44 -18
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/ARCHITECTURE.md +2 -2
  5. package/app/mcp-templates/README.md +7 -2
  6. package/app/mcp-templates/rag-mcp-legal.json +11 -0
  7. package/app/mcp-templates/rag-mcp.json +11 -0
  8. package/app/surface.json +1 -0
  9. package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
  10. package/bin/ai-toolkit.js +8 -0
  11. package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
  12. package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
  13. package/kb/procedures/ecosystem-sync-sop.md +7 -5
  14. package/kb/procedures/maintenance-sop.md +1 -1
  15. package/kb/procedures/release-verification-sop.md +35 -5
  16. package/kb/reference/architecture-overview.md +24 -5
  17. package/kb/reference/cli-reference.md +1 -1
  18. package/kb/reference/dsh-compatibility.md +183 -0
  19. package/kb/reference/manifest-install.md +112 -5
  20. package/kb/reference/mcp-templates.md +11 -4
  21. package/kb/reference/plugin-pack-conventions.md +35 -18
  22. package/kb/reference/supported-tools-registry.md +30 -6
  23. package/llms-full.txt +1110 -50
  24. package/llms.txt +3 -0
  25. package/manifest.json +2 -2
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +673 -34
  28. package/scripts/config_resolver.py +80 -14
  29. package/scripts/doctor.py +98 -20
  30. package/scripts/ecosystem_tools.json +51 -1
  31. package/scripts/generate_codex_skills.py +22 -20
  32. package/scripts/install.py +30 -13
  33. package/scripts/install_steps/ai_tools.py +97 -33
  34. package/scripts/install_steps/dsh.py +5063 -0
  35. package/scripts/install_steps/install_state.py +1645 -57
  36. package/scripts/mcp_editors.py +5 -2
  37. package/scripts/plugin.py +2495 -163
  38. package/scripts/plugin_mcp.py +279 -0
  39. package/scripts/plugin_rules.py +389 -0
  40. package/scripts/plugin_schema.py +139 -23
  41. package/scripts/uninstall.py +47 -4
  42. package/scripts/validate.py +421 -0
@@ -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
- "behavioral",
33
- "language",
34
- "domain",
35
- "integration",
36
- "plugin-pack",
37
- "policy-pack",
38
- "hook-pack",
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
- "SessionStart", "SessionEnd", "UserPromptSubmit", "Notification",
44
- "MessageDisplay", "PreToolUse", "PostToolUse", "PostToolUseFailure",
45
- "PostToolBatch", "Stop", "StopFailure", "UserPromptExpansion",
46
- "SubagentStart", "SubagentStop", "PreCompact", "PostCompact",
47
- "PermissionRequest", "PermissionDenied", "Elicitation", "ElicitationResult",
48
- "TaskCreated", "TaskCompleted", "TeammateIdle", "WorktreeCreate",
49
- "WorktreeRemove", "CwdChanged", "FileChanged", "ConfigChange",
50
- "DirectoryAdded", "Setup", "InstructionsLoaded",
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(f"Invalid status '{status}' (valid: {', '.join(sorted(VALID_STATUSES))})")
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("'hook_events' must be a dictionary mapping hook filenames to event names")
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(f"hook_events['{hook_file}']: invalid event '{event}'")
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,
@@ -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
- marker = path / CODEX_ADAPTED_SKILL_MARKER
716
- return path.is_dir() and not marker.is_symlink() and marker.is_file()
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", CODEX_ADAPTED_SKILL_MARKER}:
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)