@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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
|
|
4
|
+
# Source: https://github.com/softspark/ai-toolkit
|
|
5
|
+
|
|
6
|
+
"""Plugin-owned MCP template installation and conservative removal."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import tomllib
|
|
17
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.11+ is required
|
|
18
|
+
tomllib = None # type: ignore[assignment]
|
|
19
|
+
|
|
20
|
+
from mcp_editors import (
|
|
21
|
+
ConfigUpdate,
|
|
22
|
+
apply_config_updates,
|
|
23
|
+
prepare_install_servers,
|
|
24
|
+
prepare_remove_servers,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
TOOLKIT_DIR = Path(__file__).resolve().parent.parent
|
|
29
|
+
BUILTIN_TEMPLATES_DIR = TOOLKIT_DIR / "app" / "mcp-templates"
|
|
30
|
+
MCP_REFERENCE_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
31
|
+
LOCAL_ENDPOINT_MARKERS = ("localhost", "127.0.0.1", "[::1]")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class PluginMcpInstallPlan:
|
|
36
|
+
"""Preflighted native config update plus ownership metadata."""
|
|
37
|
+
|
|
38
|
+
updates: tuple[ConfigUpdate, ...]
|
|
39
|
+
ownership: dict
|
|
40
|
+
hints: tuple[str, ...]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class PluginMcpRemovalPlan:
|
|
45
|
+
"""Preflighted removal that preserves changed or foreign entries."""
|
|
46
|
+
|
|
47
|
+
updates: tuple[ConfigUpdate, ...]
|
|
48
|
+
removed: tuple[str, ...]
|
|
49
|
+
preserved: tuple[str, ...]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def plugin_mcp_source(plugin_name: str) -> str:
|
|
53
|
+
"""Return the stable ownership identifier for one plugin pack."""
|
|
54
|
+
if MCP_REFERENCE_PATTERN.fullmatch(plugin_name) is None:
|
|
55
|
+
raise ValueError(f"Unsafe plugin name: {plugin_name!r}")
|
|
56
|
+
return f"ai-toolkit-plugin-{plugin_name}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def prepare_plugin_mcp_install(
|
|
60
|
+
plugin_name: str,
|
|
61
|
+
editor: str,
|
|
62
|
+
pack: dict,
|
|
63
|
+
pack_dir: Path,
|
|
64
|
+
previous_ownership: dict | None,
|
|
65
|
+
) -> PluginMcpInstallPlan | None:
|
|
66
|
+
"""Resolve, validate, and preflight a plugin's global MCP installation."""
|
|
67
|
+
references = pack.get("includes", {}).get("mcp", [])
|
|
68
|
+
if not references:
|
|
69
|
+
return None
|
|
70
|
+
if not isinstance(references, list):
|
|
71
|
+
raise ValueError("includes.mcp must be a list")
|
|
72
|
+
|
|
73
|
+
servers: dict = {}
|
|
74
|
+
hints: list[str] = []
|
|
75
|
+
for reference in references:
|
|
76
|
+
template = _load_template(reference, pack_dir)
|
|
77
|
+
for server_name, server in template["mcpServers"].items():
|
|
78
|
+
if server_name in servers:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Duplicate MCP server '{server_name}' in plugin '{plugin_name}'"
|
|
81
|
+
)
|
|
82
|
+
servers[server_name] = server
|
|
83
|
+
hint = template.get("postInstall")
|
|
84
|
+
if isinstance(hint, str) and hint not in hints:
|
|
85
|
+
hints.append(hint)
|
|
86
|
+
|
|
87
|
+
updates = tuple(prepare_install_servers([editor], servers, scope="global"))
|
|
88
|
+
existing = _servers_from_updates(editor, updates, use_original=True)
|
|
89
|
+
source = plugin_mcp_source(plugin_name)
|
|
90
|
+
owned_servers = _owned_servers(previous_ownership, source)
|
|
91
|
+
for server_name in servers:
|
|
92
|
+
current = existing.get(server_name)
|
|
93
|
+
if current is None:
|
|
94
|
+
continue
|
|
95
|
+
if owned_servers.get(server_name) == current:
|
|
96
|
+
continue
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
f"Refusing user-owned MCP server collision for '{server_name}' "
|
|
99
|
+
f"in {editor}; remove or rename it explicitly"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
rendered = _servers_from_updates(editor, updates, use_original=False)
|
|
103
|
+
ownership = {
|
|
104
|
+
"source": source,
|
|
105
|
+
"servers": {name: rendered[name] for name in sorted(servers)},
|
|
106
|
+
}
|
|
107
|
+
return PluginMcpInstallPlan(
|
|
108
|
+
updates=updates,
|
|
109
|
+
ownership=ownership,
|
|
110
|
+
hints=tuple(hints),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def apply_plugin_mcp_install(plan: PluginMcpInstallPlan | None) -> None:
|
|
115
|
+
"""Commit a previously preflighted MCP install and print its operator hints."""
|
|
116
|
+
if plan is None:
|
|
117
|
+
return
|
|
118
|
+
apply_config_updates(list(plan.updates))
|
|
119
|
+
for server_name in plan.ownership["servers"]:
|
|
120
|
+
print(f" Installed MCP server: {server_name}")
|
|
121
|
+
for hint in plan.hints:
|
|
122
|
+
print(f" MCP note: {hint}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def prepare_plugin_mcp_removal(
|
|
126
|
+
plugin_name: str,
|
|
127
|
+
editor: str,
|
|
128
|
+
ownership: dict | None,
|
|
129
|
+
) -> PluginMcpRemovalPlan | None:
|
|
130
|
+
"""Preflight removal of only unchanged entries recorded as plugin-owned."""
|
|
131
|
+
source = plugin_mcp_source(plugin_name)
|
|
132
|
+
owned_servers = _owned_servers(ownership, source)
|
|
133
|
+
if not owned_servers:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
inspection = tuple(prepare_remove_servers([editor], [], scope="global"))
|
|
137
|
+
existing = _servers_from_updates(editor, inspection, use_original=True)
|
|
138
|
+
removable: list[str] = []
|
|
139
|
+
preserved: list[str] = []
|
|
140
|
+
for server_name, expected in owned_servers.items():
|
|
141
|
+
current = existing.get(server_name)
|
|
142
|
+
if current is None:
|
|
143
|
+
continue
|
|
144
|
+
if current == expected:
|
|
145
|
+
removable.append(server_name)
|
|
146
|
+
else:
|
|
147
|
+
preserved.append(server_name)
|
|
148
|
+
|
|
149
|
+
updates = (
|
|
150
|
+
tuple(prepare_remove_servers([editor], removable, scope="global"))
|
|
151
|
+
if removable
|
|
152
|
+
else ()
|
|
153
|
+
)
|
|
154
|
+
return PluginMcpRemovalPlan(
|
|
155
|
+
updates=updates,
|
|
156
|
+
removed=tuple(sorted(removable)),
|
|
157
|
+
preserved=tuple(sorted(preserved)),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def apply_plugin_mcp_removal(plan: PluginMcpRemovalPlan | None) -> None:
|
|
162
|
+
"""Commit an ownership-checked MCP removal."""
|
|
163
|
+
if plan is None:
|
|
164
|
+
return
|
|
165
|
+
apply_config_updates(list(plan.updates))
|
|
166
|
+
for server_name in plan.removed:
|
|
167
|
+
print(f" Removed MCP server: {server_name}")
|
|
168
|
+
for server_name in plan.preserved:
|
|
169
|
+
print(f" WARN preserved changed or user-owned MCP server: {server_name}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _load_template(reference: object, pack_dir: Path) -> dict:
|
|
173
|
+
if (
|
|
174
|
+
not isinstance(reference, str)
|
|
175
|
+
or MCP_REFERENCE_PATTERN.fullmatch(reference) is None
|
|
176
|
+
):
|
|
177
|
+
raise ValueError(f"Invalid includes.mcp reference: {reference!r}")
|
|
178
|
+
candidates = (
|
|
179
|
+
pack_dir / "mcp" / f"{reference}.json",
|
|
180
|
+
BUILTIN_TEMPLATES_DIR / f"{reference}.json",
|
|
181
|
+
)
|
|
182
|
+
template_path = next((path for path in candidates if path.is_file()), None)
|
|
183
|
+
if template_path is None:
|
|
184
|
+
raise FileNotFoundError(
|
|
185
|
+
f"MCP template '{reference}' not found in {pack_dir / 'mcp'} "
|
|
186
|
+
"or built-in templates"
|
|
187
|
+
)
|
|
188
|
+
if template_path.is_symlink():
|
|
189
|
+
raise RuntimeError(f"Refusing symlinked MCP template: {template_path}")
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
template = json.loads(template_path.read_text(encoding="utf-8"))
|
|
193
|
+
except json.JSONDecodeError as error:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"Invalid MCP template JSON at {template_path}: {error}"
|
|
196
|
+
) from error
|
|
197
|
+
_validate_template(reference, template, template_path)
|
|
198
|
+
return template
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _validate_template(reference: str, template: object, template_path: Path) -> None:
|
|
202
|
+
if not isinstance(template, dict):
|
|
203
|
+
raise ValueError(f"MCP template must be an object: {template_path}")
|
|
204
|
+
if template.get("name") != reference:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"MCP template name must equal includes.mcp reference '{reference}': "
|
|
207
|
+
f"{template_path}"
|
|
208
|
+
)
|
|
209
|
+
servers = template.get("mcpServers")
|
|
210
|
+
if not isinstance(servers, dict) or not servers:
|
|
211
|
+
raise ValueError(f"MCP template requires non-empty mcpServers: {template_path}")
|
|
212
|
+
if not all(_is_valid_server(name, server) for name, server in servers.items()):
|
|
213
|
+
raise ValueError(f"MCP template has invalid server entries: {template_path}")
|
|
214
|
+
|
|
215
|
+
local_remote = any(
|
|
216
|
+
isinstance(server.get("url"), str)
|
|
217
|
+
and any(marker in server["url"].lower() for marker in LOCAL_ENDPOINT_MARKERS)
|
|
218
|
+
for server in servers.values()
|
|
219
|
+
)
|
|
220
|
+
warning = template.get("postInstall", "")
|
|
221
|
+
if local_remote and (
|
|
222
|
+
not isinstance(warning, str) or "unauthenticated" not in warning.lower()
|
|
223
|
+
):
|
|
224
|
+
raise ValueError(
|
|
225
|
+
"Local HTTP MCP template must warn that access is unauthenticated: "
|
|
226
|
+
f"{template_path}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _is_valid_server(name: object, server: object) -> bool:
|
|
231
|
+
if (
|
|
232
|
+
not isinstance(name, str)
|
|
233
|
+
or MCP_REFERENCE_PATTERN.fullmatch(name) is None
|
|
234
|
+
or not isinstance(server, dict)
|
|
235
|
+
):
|
|
236
|
+
return False
|
|
237
|
+
transports = [key for key in ("command", "url") if key in server]
|
|
238
|
+
return (
|
|
239
|
+
len(transports) == 1
|
|
240
|
+
and isinstance(server[transports[0]], str)
|
|
241
|
+
and bool(server[transports[0]].strip())
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _owned_servers(ownership: object, source: str) -> dict:
|
|
246
|
+
if not isinstance(ownership, dict) or ownership.get("source") != source:
|
|
247
|
+
return {}
|
|
248
|
+
servers = ownership.get("servers")
|
|
249
|
+
if not isinstance(servers, dict):
|
|
250
|
+
return {}
|
|
251
|
+
return {
|
|
252
|
+
name: config
|
|
253
|
+
for name, config in servers.items()
|
|
254
|
+
if isinstance(name, str) and isinstance(config, dict)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _servers_from_updates(
|
|
259
|
+
editor: str,
|
|
260
|
+
updates: tuple[ConfigUpdate, ...],
|
|
261
|
+
*,
|
|
262
|
+
use_original: bool,
|
|
263
|
+
) -> dict:
|
|
264
|
+
if not updates:
|
|
265
|
+
return {}
|
|
266
|
+
payload = updates[0].original if use_original else updates[0].content
|
|
267
|
+
if payload is None:
|
|
268
|
+
return {}
|
|
269
|
+
if editor == "codex":
|
|
270
|
+
if tomllib is None: # pragma: no cover
|
|
271
|
+
raise RuntimeError("tomllib is unavailable")
|
|
272
|
+
document = tomllib.loads(payload.decode("utf-8"))
|
|
273
|
+
servers = document.get("mcp_servers", {})
|
|
274
|
+
else:
|
|
275
|
+
document = json.loads(payload.decode("utf-8"))
|
|
276
|
+
servers = document.get("mcpServers", {})
|
|
277
|
+
if not isinstance(servers, dict):
|
|
278
|
+
raise ValueError("Native MCP config has invalid server data")
|
|
279
|
+
return servers
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
|
|
4
|
+
# Source: https://github.com/softspark/ai-toolkit
|
|
5
|
+
|
|
6
|
+
"""Owned editor-native rule files for generic plugin runtimes."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from injection import markers_end, markers_start, trim_trailing_blanks
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SAFE_NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
21
|
+
MARKER_PATTERN = re.compile(
|
|
22
|
+
r"<!-- TOOLKIT:(?P<section>[A-Za-z0-9][A-Za-z0-9._:-]*) "
|
|
23
|
+
r"(?P<kind>START|END) -->"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class RuleFileUpdate:
|
|
29
|
+
"""One preflighted rule file replacement."""
|
|
30
|
+
|
|
31
|
+
path: Path
|
|
32
|
+
original: bytes | None
|
|
33
|
+
content: bytes | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class PluginRulePlan:
|
|
38
|
+
"""Preflighted native rule updates plus state ownership metadata."""
|
|
39
|
+
|
|
40
|
+
updates: tuple[RuleFileUpdate, ...]
|
|
41
|
+
ownership: dict | None
|
|
42
|
+
installed: tuple[str, ...] = ()
|
|
43
|
+
removed: tuple[str, ...] = ()
|
|
44
|
+
preserved: tuple[str, ...] = ()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class OwnedMarkerSpan:
|
|
49
|
+
"""One unambiguous, balanced marker span in a Gemini context file."""
|
|
50
|
+
|
|
51
|
+
start: int
|
|
52
|
+
end: int
|
|
53
|
+
block: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def plugin_rule_source(plugin_name: str) -> str:
|
|
57
|
+
"""Return the stable ownership identifier for plugin rules."""
|
|
58
|
+
_require_safe_name(plugin_name, "plugin")
|
|
59
|
+
return f"ai-toolkit-plugin-{plugin_name}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def prepare_plugin_rule_install(
|
|
63
|
+
plugin_name: str,
|
|
64
|
+
editor: str,
|
|
65
|
+
rule_specs: list[dict],
|
|
66
|
+
previous_ownership: dict | None,
|
|
67
|
+
) -> PluginRulePlan | None:
|
|
68
|
+
"""Preflight pack-owned rules for an editor's global native surface."""
|
|
69
|
+
owned_specs = [spec for spec in rule_specs if not spec.get("is_core")]
|
|
70
|
+
if not owned_specs:
|
|
71
|
+
return None
|
|
72
|
+
if editor == "gemini":
|
|
73
|
+
return _prepare_gemini_install(
|
|
74
|
+
plugin_name,
|
|
75
|
+
owned_specs,
|
|
76
|
+
previous_ownership,
|
|
77
|
+
)
|
|
78
|
+
if editor != "cursor":
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
source = plugin_rule_source(plugin_name)
|
|
82
|
+
previous_entries = _owned_entries(previous_ownership, source)
|
|
83
|
+
updates: list[RuleFileUpdate] = []
|
|
84
|
+
entries: dict[str, dict[str, str]] = {}
|
|
85
|
+
installed: list[str] = []
|
|
86
|
+
for spec in owned_specs:
|
|
87
|
+
rule_name = _require_safe_name(spec.get("name"), "rule")
|
|
88
|
+
source_path = Path(spec["source"])
|
|
89
|
+
if source_path.is_symlink() or not source_path.is_file():
|
|
90
|
+
raise RuntimeError(f"Refusing unsafe plugin rule source: {source_path}")
|
|
91
|
+
path = (
|
|
92
|
+
Path.home() / ".cursor" / "rules" / f"plugin-{plugin_name}-{rule_name}.mdc"
|
|
93
|
+
)
|
|
94
|
+
content = _render_cursor_rule(rule_name, source_path)
|
|
95
|
+
original = _read_optional(path)
|
|
96
|
+
previous = previous_entries.get(rule_name, {})
|
|
97
|
+
if original is not None and not _matches_owned_file(path, original, previous):
|
|
98
|
+
raise RuntimeError(f"Refusing user-owned plugin rule collision: {path}")
|
|
99
|
+
updates.append(RuleFileUpdate(path=path, original=original, content=content))
|
|
100
|
+
entries[rule_name] = {
|
|
101
|
+
"path": str(path),
|
|
102
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
103
|
+
}
|
|
104
|
+
installed.append(rule_name)
|
|
105
|
+
|
|
106
|
+
return PluginRulePlan(
|
|
107
|
+
updates=tuple(updates),
|
|
108
|
+
ownership={"source": source, "entries": entries},
|
|
109
|
+
installed=tuple(sorted(installed)),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def prepare_plugin_rule_removal(
|
|
114
|
+
plugin_name: str,
|
|
115
|
+
editor: str,
|
|
116
|
+
ownership: dict | None,
|
|
117
|
+
) -> PluginRulePlan | None:
|
|
118
|
+
"""Remove only unchanged rule files recorded as plugin-owned."""
|
|
119
|
+
source = plugin_rule_source(plugin_name)
|
|
120
|
+
entries = _owned_entries(ownership, source)
|
|
121
|
+
if not entries:
|
|
122
|
+
return None
|
|
123
|
+
if editor == "gemini":
|
|
124
|
+
return _prepare_gemini_removal(plugin_name, entries)
|
|
125
|
+
if editor != "cursor":
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
updates: list[RuleFileUpdate] = []
|
|
129
|
+
removed: list[str] = []
|
|
130
|
+
preserved: list[str] = []
|
|
131
|
+
for rule_name, entry in entries.items():
|
|
132
|
+
path = Path(entry.get("path", ""))
|
|
133
|
+
expected_path = (
|
|
134
|
+
Path.home() / ".cursor" / "rules" / f"plugin-{plugin_name}-{rule_name}.mdc"
|
|
135
|
+
)
|
|
136
|
+
if path != expected_path:
|
|
137
|
+
preserved.append(rule_name)
|
|
138
|
+
continue
|
|
139
|
+
original = _read_optional(path)
|
|
140
|
+
if original is None:
|
|
141
|
+
continue
|
|
142
|
+
if _matches_owned_file(path, original, entry):
|
|
143
|
+
updates.append(RuleFileUpdate(path=path, original=original, content=None))
|
|
144
|
+
removed.append(rule_name)
|
|
145
|
+
else:
|
|
146
|
+
preserved.append(rule_name)
|
|
147
|
+
return PluginRulePlan(
|
|
148
|
+
updates=tuple(updates),
|
|
149
|
+
ownership=None,
|
|
150
|
+
removed=tuple(sorted(removed)),
|
|
151
|
+
preserved=tuple(sorted(preserved)),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _render_cursor_rule(rule_name: str, source_path: Path) -> bytes:
|
|
156
|
+
content = source_path.read_text(encoding="utf-8").rstrip("\n")
|
|
157
|
+
rendered = (
|
|
158
|
+
"---\n"
|
|
159
|
+
f"description: Plugin rule: {rule_name}\n"
|
|
160
|
+
"alwaysApply: true\n"
|
|
161
|
+
"---\n\n"
|
|
162
|
+
f"{content}\n"
|
|
163
|
+
)
|
|
164
|
+
return rendered.encode("utf-8")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _prepare_gemini_install(
|
|
168
|
+
plugin_name: str,
|
|
169
|
+
rule_specs: list[dict],
|
|
170
|
+
previous_ownership: dict | None,
|
|
171
|
+
) -> PluginRulePlan:
|
|
172
|
+
source = plugin_rule_source(plugin_name)
|
|
173
|
+
previous_entries = _owned_entries(previous_ownership, source)
|
|
174
|
+
path = Path.home() / ".gemini" / "GEMINI.md"
|
|
175
|
+
original = _read_optional(path)
|
|
176
|
+
working = original.decode("utf-8") if original is not None else ""
|
|
177
|
+
blocks: list[str] = []
|
|
178
|
+
entries: dict[str, dict[str, str]] = {}
|
|
179
|
+
installed: list[str] = []
|
|
180
|
+
|
|
181
|
+
for spec in rule_specs:
|
|
182
|
+
rule_name = _require_safe_name(spec.get("name"), "rule")
|
|
183
|
+
source_path = Path(spec["source"])
|
|
184
|
+
if source_path.is_symlink() or not source_path.is_file():
|
|
185
|
+
raise RuntimeError(f"Refusing unsafe plugin rule source: {source_path}")
|
|
186
|
+
section = f"plugin-{plugin_name}-{rule_name}"
|
|
187
|
+
current_span = _owned_marker_span(working, section)
|
|
188
|
+
previous = previous_entries.get(rule_name, {})
|
|
189
|
+
if current_span is not None:
|
|
190
|
+
if not _matches_owned_section(path, section, current_span.block, previous):
|
|
191
|
+
raise RuntimeError(
|
|
192
|
+
f"Refusing user-owned plugin rule section collision: {section}"
|
|
193
|
+
)
|
|
194
|
+
working = _remove_marker_span(working, current_span)
|
|
195
|
+
|
|
196
|
+
content = source_path.read_text(encoding="utf-8").rstrip("\n")
|
|
197
|
+
block = markers_start(section) + content + markers_end(section)
|
|
198
|
+
blocks.append(block)
|
|
199
|
+
entries[rule_name] = {
|
|
200
|
+
"path": str(path),
|
|
201
|
+
"section": section,
|
|
202
|
+
"sha256": hashlib.sha256(block.encode("utf-8")).hexdigest(),
|
|
203
|
+
}
|
|
204
|
+
installed.append(rule_name)
|
|
205
|
+
|
|
206
|
+
base = trim_trailing_blanks(working)
|
|
207
|
+
parts = [base] if base else []
|
|
208
|
+
parts.extend(blocks)
|
|
209
|
+
rendered = ("\n\n".join(parts) + "\n").encode("utf-8")
|
|
210
|
+
return PluginRulePlan(
|
|
211
|
+
updates=(RuleFileUpdate(path=path, original=original, content=rendered),),
|
|
212
|
+
ownership={"source": source, "entries": entries},
|
|
213
|
+
installed=tuple(sorted(installed)),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _prepare_gemini_removal(
|
|
218
|
+
plugin_name: str,
|
|
219
|
+
entries: dict[str, dict],
|
|
220
|
+
) -> PluginRulePlan:
|
|
221
|
+
path = Path.home() / ".gemini" / "GEMINI.md"
|
|
222
|
+
original = _read_optional(path)
|
|
223
|
+
if original is None:
|
|
224
|
+
return PluginRulePlan(updates=(), ownership=None)
|
|
225
|
+
working = original.decode("utf-8")
|
|
226
|
+
removed: list[str] = []
|
|
227
|
+
preserved: list[str] = []
|
|
228
|
+
for rule_name, entry in entries.items():
|
|
229
|
+
section = f"plugin-{plugin_name}-{rule_name}"
|
|
230
|
+
span = _owned_marker_span(working, section)
|
|
231
|
+
if span is None:
|
|
232
|
+
continue
|
|
233
|
+
if _matches_owned_section(path, section, span.block, entry):
|
|
234
|
+
working = _remove_marker_span(working, span)
|
|
235
|
+
removed.append(rule_name)
|
|
236
|
+
else:
|
|
237
|
+
preserved.append(rule_name)
|
|
238
|
+
|
|
239
|
+
trimmed = trim_trailing_blanks(working)
|
|
240
|
+
content = (trimmed + "\n").encode("utf-8") if trimmed else None
|
|
241
|
+
updates = (
|
|
242
|
+
(RuleFileUpdate(path=path, original=original, content=content),)
|
|
243
|
+
if removed
|
|
244
|
+
else ()
|
|
245
|
+
)
|
|
246
|
+
return PluginRulePlan(
|
|
247
|
+
updates=updates,
|
|
248
|
+
ownership=None,
|
|
249
|
+
removed=tuple(sorted(removed)),
|
|
250
|
+
preserved=tuple(sorted(preserved)),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _owned_marker_span(content: str, section: str) -> OwnedMarkerSpan | None:
|
|
255
|
+
"""Find exactly one balanced, non-crossed marker span for ``section``."""
|
|
256
|
+
markers = tuple(MARKER_PATTERN.finditer(content))
|
|
257
|
+
owned = tuple(match for match in markers if match.group("section") == section)
|
|
258
|
+
if not owned:
|
|
259
|
+
return None
|
|
260
|
+
starts = tuple(match for match in owned if match.group("kind") == "START")
|
|
261
|
+
ends = tuple(match for match in owned if match.group("kind") == "END")
|
|
262
|
+
if len(starts) != 1 or len(ends) != 1:
|
|
263
|
+
raise RuntimeError(f"Malformed or duplicate owned Gemini markers: {section}")
|
|
264
|
+
|
|
265
|
+
stack: list[re.Match[str]] = []
|
|
266
|
+
for marker in markers:
|
|
267
|
+
marker_section = marker.group("section")
|
|
268
|
+
if marker.group("kind") == "START":
|
|
269
|
+
if stack and any(item.group("section") == section for item in stack):
|
|
270
|
+
raise RuntimeError(f"Nested owned Gemini marker span: {section}")
|
|
271
|
+
stack.append(marker)
|
|
272
|
+
continue
|
|
273
|
+
if not stack:
|
|
274
|
+
if marker_section == section:
|
|
275
|
+
raise RuntimeError(f"Orphaned owned Gemini marker: {section}")
|
|
276
|
+
continue
|
|
277
|
+
opened = stack[-1]
|
|
278
|
+
if opened.group("section") != marker_section:
|
|
279
|
+
if marker_section == section or any(
|
|
280
|
+
item.group("section") == section for item in stack
|
|
281
|
+
):
|
|
282
|
+
raise RuntimeError(f"Crossed owned Gemini marker span: {section}")
|
|
283
|
+
continue
|
|
284
|
+
stack.pop()
|
|
285
|
+
if marker_section == section:
|
|
286
|
+
start_index = opened.start()
|
|
287
|
+
end_index = marker.end()
|
|
288
|
+
return OwnedMarkerSpan(
|
|
289
|
+
start=start_index,
|
|
290
|
+
end=end_index,
|
|
291
|
+
block=content[start_index:end_index],
|
|
292
|
+
)
|
|
293
|
+
raise RuntimeError(f"Unbalanced owned Gemini marker span: {section}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _remove_marker_span(content: str, span: OwnedMarkerSpan) -> str:
|
|
297
|
+
"""Remove only the byte-equivalent character span already verified above."""
|
|
298
|
+
return content[: span.start] + content[span.end :]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _matches_owned_section(
|
|
302
|
+
path: Path,
|
|
303
|
+
section: str,
|
|
304
|
+
block: str,
|
|
305
|
+
entry: object,
|
|
306
|
+
) -> bool:
|
|
307
|
+
if not isinstance(entry, dict):
|
|
308
|
+
return False
|
|
309
|
+
if entry.get("path") != str(path) or entry.get("section") != section:
|
|
310
|
+
return False
|
|
311
|
+
expected_hash = entry.get("sha256")
|
|
312
|
+
return (
|
|
313
|
+
isinstance(expected_hash, str)
|
|
314
|
+
and hashlib.sha256(block.encode("utf-8")).hexdigest() == expected_hash
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _owned_entries(ownership: object, source: str) -> dict[str, dict]:
|
|
319
|
+
if not isinstance(ownership, dict) or ownership.get("source") != source:
|
|
320
|
+
return {}
|
|
321
|
+
entries = ownership.get("entries")
|
|
322
|
+
if not isinstance(entries, dict):
|
|
323
|
+
return {}
|
|
324
|
+
return {
|
|
325
|
+
name: entry
|
|
326
|
+
for name, entry in entries.items()
|
|
327
|
+
if isinstance(name, str) and isinstance(entry, dict)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _matches_owned_file(path: Path, content: bytes, entry: object) -> bool:
|
|
332
|
+
if not isinstance(entry, dict) or entry.get("path") != str(path):
|
|
333
|
+
return False
|
|
334
|
+
expected_hash = entry.get("sha256")
|
|
335
|
+
return (
|
|
336
|
+
isinstance(expected_hash, str)
|
|
337
|
+
and hashlib.sha256(content).hexdigest() == expected_hash
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _require_safe_name(value: object, label: str) -> str:
|
|
342
|
+
if not isinstance(value, str) or SAFE_NAME_PATTERN.fullmatch(value) is None:
|
|
343
|
+
raise ValueError(f"Unsafe {label} name: {value!r}")
|
|
344
|
+
return value
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _read_optional(path: Path) -> bytes | None:
|
|
348
|
+
_assert_safe_path(path)
|
|
349
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
350
|
+
try:
|
|
351
|
+
descriptor = os.open(path, flags)
|
|
352
|
+
except FileNotFoundError:
|
|
353
|
+
return None
|
|
354
|
+
try:
|
|
355
|
+
info = os.fstat(descriptor)
|
|
356
|
+
if not stat.S_ISREG(info.st_mode):
|
|
357
|
+
raise RuntimeError(f"Plugin rule destination is not a file: {path}")
|
|
358
|
+
chunks: list[bytes] = []
|
|
359
|
+
while True:
|
|
360
|
+
chunk = os.read(descriptor, 1024 * 1024)
|
|
361
|
+
if not chunk:
|
|
362
|
+
break
|
|
363
|
+
chunks.append(chunk)
|
|
364
|
+
return b"".join(chunks)
|
|
365
|
+
finally:
|
|
366
|
+
os.close(descriptor)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _assert_safe_path(path: Path) -> None:
|
|
370
|
+
absolute = path.expanduser().absolute()
|
|
371
|
+
home = Path.home().absolute()
|
|
372
|
+
try:
|
|
373
|
+
relative = absolute.relative_to(home)
|
|
374
|
+
except ValueError as error:
|
|
375
|
+
raise RuntimeError(f"Plugin rule path escapes HOME: {absolute}") from error
|
|
376
|
+
current = home
|
|
377
|
+
root_info = os.lstat(current)
|
|
378
|
+
if stat.S_ISLNK(root_info.st_mode) or not stat.S_ISDIR(root_info.st_mode):
|
|
379
|
+
raise RuntimeError(f"Unsafe plugin rule HOME: {current}")
|
|
380
|
+
for part in relative.parts[:-1]:
|
|
381
|
+
current /= part
|
|
382
|
+
try:
|
|
383
|
+
info = os.lstat(current)
|
|
384
|
+
except FileNotFoundError:
|
|
385
|
+
continue
|
|
386
|
+
if stat.S_ISLNK(info.st_mode):
|
|
387
|
+
raise RuntimeError(f"Refusing symlinked plugin rule path: {current}")
|
|
388
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
389
|
+
raise RuntimeError(f"Plugin rule ancestor is not a directory: {current}")
|