spawn-cli 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,221 @@
1
+ """Detect vacant IDE-managed filesystem trees after teardown (skills/MCP removals)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+
10
+ def json_value_nonempty(v: object) -> bool:
11
+ """True if nested JSON-ish value carries user-visible substance."""
12
+ if v is None:
13
+ return False
14
+ if isinstance(v, dict):
15
+ return len(v) > 0
16
+ if isinstance(v, list):
17
+ return len(v) > 0
18
+ if isinstance(v, str):
19
+ return bool(v.strip())
20
+ if isinstance(v, bool):
21
+ return v
22
+ if isinstance(v, (int, float)):
23
+ return v != 0
24
+ return True
25
+
26
+
27
+ def mcp_json_mcp_servers_format_is_empty(path: Path) -> bool:
28
+ """True when path is Cursor or Claude-root style JSON ({mcpServers?: ...})."""
29
+ if not path.is_file():
30
+ return False
31
+ try:
32
+ raw = path.read_text(encoding="utf-8")
33
+ data = json.loads(raw)
34
+ except (OSError, json.JSONDecodeError):
35
+ return False
36
+ if not isinstance(data, dict):
37
+ return False
38
+ if data == {}:
39
+ return True
40
+ servers = data.get("mcpServers")
41
+ srv_empty = servers in (None, {}) or (isinstance(servers, dict) and len(servers) == 0)
42
+ if not srv_empty:
43
+ return False
44
+ for k, v in data.items():
45
+ if k != "mcpServers" and json_value_nonempty(v):
46
+ return False
47
+ return True
48
+
49
+
50
+ def vscode_servers_mcp_json_is_empty(path: Path) -> bool:
51
+ """True when path is VS Code MCP format (`servers`, `inputs`). Used for .vscode/mcp.json."""
52
+ if not path.is_file():
53
+ return False
54
+ try:
55
+ data = json.loads(path.read_text(encoding="utf-8"))
56
+ except (OSError, json.JSONDecodeError):
57
+ return False
58
+ if not isinstance(data, dict):
59
+ return False
60
+ if data == {}:
61
+ return True
62
+ servers = data.get("servers")
63
+ srv_empty = servers in (None, {}) or (isinstance(servers, dict) and len(servers) == 0)
64
+ inputs = data.get("inputs")
65
+ inp_empty = inputs in (None, []) or (isinstance(inputs, list) and len(inputs) == 0)
66
+ if not (srv_empty and inp_empty):
67
+ return False
68
+ for k, v in data.items():
69
+ if k not in ("servers", "inputs") and json_value_nonempty(v):
70
+ return False
71
+ return True
72
+
73
+
74
+ def dir_has_any_file(root: Path) -> bool:
75
+ """True if any regular file exists under root."""
76
+ if not root.is_dir():
77
+ return False
78
+ try:
79
+ for p in root.rglob("*"):
80
+ if p.is_file():
81
+ return True
82
+ except OSError:
83
+ return True
84
+ return False
85
+
86
+
87
+ def prune_empty_directories_under(container: Path) -> None:
88
+ """Remove descendant directories bottom-up while empty (excluding container itself)."""
89
+ if not container.is_dir():
90
+ return
91
+ dirs = [p for p in container.rglob("*") if p.is_dir()]
92
+ dirs.sort(key=lambda p: len(p.parts), reverse=True)
93
+ for d in dirs:
94
+ try:
95
+ if not any(d.iterdir()):
96
+ d.rmdir()
97
+ except OSError:
98
+ pass
99
+
100
+
101
+ def ide_dotdir_is_entirely_removable(root: Path, *, allow_delete_entire: bool) -> bool:
102
+ """
103
+ Whole-directory removal permitted only when enabled and container has zero files beneath.
104
+ """
105
+ if not allow_delete_entire or not root.exists():
106
+ return False
107
+ return not dir_has_any_file(root)
108
+
109
+
110
+ def try_unlink_empty_mcp_mcp_servers(path: Path) -> None:
111
+ if mcp_json_mcp_servers_format_is_empty(path):
112
+ path.unlink(missing_ok=True)
113
+
114
+
115
+ def try_unlink_empty_vscode_mcp(path: Path) -> None:
116
+ if vscode_servers_mcp_json_is_empty(path):
117
+ path.unlink(missing_ok=True)
118
+
119
+
120
+ def finalize_standard_dotdir_skills_and_mcp(
121
+ target_root: Path,
122
+ rel_dir: str,
123
+ *,
124
+ allow_delete_entire: bool,
125
+ unlink_settings_json_when_mcp_servers_empty: bool = False,
126
+ ) -> None:
127
+ """Shared layout: IDE dotdir plus optional MCP JSON files."""
128
+ root = target_root / rel_dir
129
+ if not root.is_dir():
130
+ return
131
+ try_unlink_empty_mcp_mcp_servers(root / "mcp.json")
132
+ if unlink_settings_json_when_mcp_servers_empty:
133
+ sj = root / "settings.json"
134
+ if sj.is_file():
135
+ try_unlink_empty_mcp_mcp_servers(sj)
136
+ prune_empty_directories_under(root)
137
+ if ide_dotdir_is_entirely_removable(root, allow_delete_entire=allow_delete_entire):
138
+ shutil.rmtree(root, ignore_errors=True)
139
+
140
+
141
+ def claude_root_mcp_maybe_unlink(target_root: Path) -> None:
142
+ try_unlink_empty_mcp_mcp_servers(target_root / ".mcp.json")
143
+
144
+
145
+ def claude_settings_maybe_unlink(path: Path) -> None:
146
+ if not path.is_file():
147
+ return
148
+ try:
149
+ data = json.loads(path.read_text(encoding="utf-8"))
150
+ except (OSError, json.JSONDecodeError):
151
+ return
152
+ if data == {}:
153
+ path.unlink(missing_ok=True)
154
+ return
155
+ if not isinstance(data, dict):
156
+ return
157
+ perm = data.get("permissions")
158
+ if isinstance(perm, dict) and perm.get("deny") == [] and set(data.keys()) == {"permissions"}:
159
+ if set(perm.keys()) <= {"deny"}:
160
+ path.unlink(missing_ok=True)
161
+
162
+
163
+ def finalize_claude_repo(target_root: Path) -> None:
164
+ """Claude adapter: repo `.mcp.json`, `.claude/{skills,settings,...}`, drop `.claude` if vacant."""
165
+ claude_root_mcp_maybe_unlink(target_root)
166
+ settings = target_root / ".claude" / "settings.json"
167
+ claude_settings_maybe_unlink(settings)
168
+ finalize_standard_dotdir_skills_and_mcp(
169
+ target_root,
170
+ ".claude",
171
+ allow_delete_entire=True,
172
+ unlink_settings_json_when_mcp_servers_empty=False,
173
+ )
174
+
175
+
176
+ def finalize_codex_repo(target_root: Path) -> None:
177
+ from spawn_cli.io.toml_io import load_toml
178
+
179
+ agents = target_root / ".agents"
180
+ codex_cfg = target_root / ".codex" / "config.toml"
181
+ if codex_cfg.is_file():
182
+ data = load_toml(codex_cfg)
183
+ if isinstance(data, dict) and (
184
+ data == {} or (set(data.keys()) <= {"mcp_servers"} and not data.get("mcp_servers"))
185
+ ):
186
+ codex_cfg.unlink(missing_ok=True)
187
+ prune_empty_directories_under(codex_cfg.parent)
188
+ prune_empty_directories_under(agents)
189
+ codex_root = target_root / ".codex"
190
+ try:
191
+ if codex_root.is_dir() and not any(codex_root.iterdir()):
192
+ codex_root.rmdir()
193
+ except OSError:
194
+ pass
195
+ if agents.is_dir() and not dir_has_any_file(agents):
196
+ shutil.rmtree(agents, ignore_errors=True)
197
+
198
+
199
+ def finalize_github_copilot_repo(target_root: Path) -> None:
200
+ try_unlink_empty_vscode_mcp(target_root / ".vscode" / "mcp.json")
201
+ prune_empty_directories_under(target_root / ".github" / "skills")
202
+ sk = target_root / ".github" / "skills"
203
+ try:
204
+ if sk.is_dir() and not any(sk.iterdir()):
205
+ sk.rmdir()
206
+ except OSError:
207
+ pass
208
+
209
+
210
+ __all__ = [
211
+ "dir_has_any_file",
212
+ "finalize_claude_repo",
213
+ "finalize_codex_repo",
214
+ "finalize_github_copilot_repo",
215
+ "finalize_standard_dotdir_skills_and_mcp",
216
+ "ide_dotdir_is_entirely_removable",
217
+ "json_value_nonempty",
218
+ "mcp_json_mcp_servers_format_is_empty",
219
+ "prune_empty_directories_under",
220
+ "vscode_servers_mcp_json_is_empty",
221
+ ]
@@ -0,0 +1,145 @@
1
+ """Anthropic Claude Code IDE adapter: .claude/skills, root .mcp.json, settings permissions, CLAUDE.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import warnings
7
+ from pathlib import Path
8
+
9
+ from spawn_cli.ide import _vacancy as _vac
10
+ from spawn_cli.ide.registry import (
11
+ DetectResult,
12
+ IdeAdapter,
13
+ IdeCapabilities,
14
+ register,
15
+ normalize_skill_name,
16
+ render_skill_md,
17
+ rewrite_managed_block,
18
+ )
19
+ from spawn_cli.models.mcp import McpServer, NormalizedMcp
20
+ from spawn_cli.models.skill import SkillMetadata
21
+
22
+
23
+ def _build_generic_mcp_entry(server: McpServer) -> dict:
24
+ transport = server.transport
25
+ if transport.type == "stdio":
26
+ entry: dict = {"command": transport.command, "args": list(transport.args)}
27
+ elif transport.type in ("streamable-http", "sse"):
28
+ entry = {"type": transport.type, "url": transport.url}
29
+ if transport.headers:
30
+ entry["headers"] = {k: f"${{{v}}}" for k, v in transport.headers.items()}
31
+ else:
32
+ entry = {"command": transport.command, "args": list(transport.args)}
33
+ if server.env:
34
+ entry["env"] = {
35
+ k: f"${{{k}}}" if v.secret else v.value or f"${{{k}}}"
36
+ for k, v in server.env.items()
37
+ }
38
+ return entry
39
+
40
+
41
+ class ClaudeCodeAdapter(IdeAdapter):
42
+ key = "claude-code"
43
+
44
+ def detect(self, target_root: Path) -> DetectResult:
45
+ used = (target_root / ".claude").exists() or (target_root / "CLAUDE.md").exists()
46
+ return DetectResult(
47
+ used_in_repo=used,
48
+ capabilities=IdeCapabilities(
49
+ skills="native",
50
+ mcp="project",
51
+ agent_ignore="project",
52
+ entry_point="claude-md",
53
+ ),
54
+ )
55
+
56
+ def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
57
+ results = []
58
+ for skill in skill_metadata:
59
+ name = normalize_skill_name(skill.name)
60
+ skill_dir = target_root / ".claude" / "skills" / name
61
+ skill_dir.mkdir(parents=True, exist_ok=True)
62
+ skill_path = skill_dir / "SKILL.md"
63
+ if skill_path.exists():
64
+ warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
65
+ skill_path.write_text(render_skill_md(skill), encoding="utf-8")
66
+ rel = skill_path.relative_to(target_root).as_posix()
67
+ results.append({"skill": skill.name, "path": rel})
68
+ return results
69
+
70
+ def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
71
+ for entry in rendered_paths:
72
+ p = target_root / entry["path"]
73
+ if p.exists():
74
+ p.unlink()
75
+ if p.parent.exists() and not any(p.parent.iterdir()):
76
+ p.parent.rmdir()
77
+
78
+ def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
79
+ mcp_path = target_root / ".mcp.json"
80
+ data = json.loads(mcp_path.read_text(encoding="utf-8")) if mcp_path.exists() else {}
81
+ data.setdefault("mcpServers", {})
82
+ rendered_names = []
83
+ for server in normalized_mcp.servers:
84
+ data["mcpServers"][server.name] = _build_generic_mcp_entry(server)
85
+ rendered_names.append(server.name)
86
+ mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
87
+ return rendered_names
88
+
89
+ def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
90
+ mcp_path = target_root / ".mcp.json"
91
+ if not mcp_path.exists():
92
+ return
93
+ data = json.loads(mcp_path.read_text(encoding="utf-8"))
94
+ for name in rendered_mcp_names:
95
+ data.get("mcpServers", {}).pop(name, None)
96
+ mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
97
+
98
+ def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
99
+ settings_path = target_root / ".claude" / "settings.json"
100
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
101
+ data = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.exists() else {}
102
+ perms = data.setdefault("permissions", {})
103
+ deny = perms.setdefault("deny", [])
104
+ if not isinstance(deny, list):
105
+ deny_list: list = []
106
+ perms["deny"] = deny_list
107
+ else:
108
+ deny_list = deny
109
+ for glob in globs:
110
+ if glob not in deny_list:
111
+ deny_list.append(glob)
112
+ settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
113
+
114
+ def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
115
+ settings_path = target_root / ".claude" / "settings.json"
116
+ if not settings_path.exists():
117
+ return
118
+ data = json.loads(settings_path.read_text(encoding="utf-8"))
119
+ perms = data.get("permissions")
120
+ if not isinstance(perms, dict):
121
+ return
122
+ deny = perms.get("deny")
123
+ if not isinstance(deny, list):
124
+ return
125
+ drop = set(globs)
126
+ perms["deny"] = [g for g in deny if g not in drop]
127
+ settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
128
+
129
+ def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
130
+ ep = target_root / "CLAUDE.md"
131
+ if (target_root / ".claude" / "CLAUDE.md").exists():
132
+ warnings.warn(
133
+ "Claude Code: .claude/CLAUDE.md exists but Spawn writes to repo-root CLAUDE.md. "
134
+ "Update .claude/CLAUDE.md manually if needed."
135
+ )
136
+ rewrite_managed_block(ep, prompt)
137
+ return ep.relative_to(target_root).as_posix()
138
+
139
+ def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
140
+ _vac.finalize_claude_repo(target_root)
141
+
142
+
143
+ register(ClaudeCodeAdapter())
144
+
145
+ __all__ = ["ClaudeCodeAdapter"]
spawn_cli/ide/codex.py ADDED
@@ -0,0 +1,120 @@
1
+ """OpenAI Codex IDE adapter: skills under .agents/, MCP in .codex/config.toml, AGENTS.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from pathlib import Path
7
+
8
+ from spawn_cli.ide import _vacancy as _vac
9
+ from spawn_cli.ide.registry import (
10
+ DetectResult,
11
+ IdeAdapter,
12
+ IdeCapabilities,
13
+ register,
14
+ normalize_skill_name,
15
+ render_skill_md,
16
+ rewrite_managed_block,
17
+ )
18
+ from spawn_cli.io.toml_io import load_toml, save_toml
19
+ from spawn_cli.models.mcp import McpServer, NormalizedMcp
20
+ from spawn_cli.models.skill import SkillMetadata
21
+
22
+
23
+ def _build_toml_server_entry(server: McpServer) -> dict:
24
+ transport = server.transport
25
+ entry: dict = {}
26
+ if transport.type == "stdio":
27
+ entry["command"] = transport.command
28
+ entry["args"] = list(transport.args)
29
+ else:
30
+ entry["url"] = transport.url
31
+ if server.env:
32
+ entry["env"] = {
33
+ k: f"${{{k}}}" if v.secret else (v.value or f"${{{k}}}")
34
+ for k, v in server.env.items()
35
+ }
36
+ return entry
37
+
38
+
39
+ class CodexAdapter(IdeAdapter):
40
+ key = "codex"
41
+
42
+ def detect(self, target_root: Path) -> DetectResult:
43
+ used = (target_root / ".codex").exists() or (target_root / ".agents").exists()
44
+ return DetectResult(
45
+ used_in_repo=used,
46
+ capabilities=IdeCapabilities(
47
+ skills="native",
48
+ mcp="project",
49
+ agent_ignore="unsupported",
50
+ entry_point="agents-md",
51
+ ),
52
+ )
53
+
54
+ def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
55
+ results = []
56
+ for skill in skill_metadata:
57
+ name = normalize_skill_name(skill.name)
58
+ skill_dir = target_root / ".agents" / "skills" / name
59
+ skill_dir.mkdir(parents=True, exist_ok=True)
60
+ skill_path = skill_dir / "SKILL.md"
61
+ if skill_path.exists():
62
+ warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
63
+ skill_path.write_text(render_skill_md(skill), encoding="utf-8")
64
+ rel = skill_path.relative_to(target_root).as_posix()
65
+ results.append({"skill": skill.name, "path": rel})
66
+ return results
67
+
68
+ def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
69
+ for entry in rendered_paths:
70
+ p = target_root / entry["path"]
71
+ if p.exists():
72
+ p.unlink()
73
+ if p.parent.exists() and not any(p.parent.iterdir()):
74
+ p.parent.rmdir()
75
+
76
+ def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
77
+ config_path = target_root / ".codex" / "config.toml"
78
+ data = load_toml(config_path)
79
+ mcp_servers = data.setdefault("mcp_servers", {})
80
+ if not isinstance(mcp_servers, dict):
81
+ mcp_servers = {}
82
+ data["mcp_servers"] = mcp_servers
83
+ rendered_names = []
84
+ for server in normalized_mcp.servers:
85
+ mcp_servers[server.name] = _build_toml_server_entry(server)
86
+ rendered_names.append(server.name)
87
+ save_toml(config_path, data)
88
+ return rendered_names
89
+
90
+ def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
91
+ config_path = target_root / ".codex" / "config.toml"
92
+ if not config_path.is_file():
93
+ return
94
+ data = load_toml(config_path)
95
+ mcp_servers = data.get("mcp_servers")
96
+ if isinstance(mcp_servers, dict):
97
+ for name in rendered_mcp_names:
98
+ mcp_servers.pop(name, None)
99
+ save_toml(config_path, data)
100
+
101
+ def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
102
+ del target_root, globs
103
+ warnings.warn("codex: agent ignore is unsupported; steer via AGENTS.md policy instead")
104
+
105
+ def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
106
+ del target_root, globs
107
+ warnings.warn("codex: agent ignore is unsupported")
108
+
109
+ def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
110
+ ep = target_root / "AGENTS.md"
111
+ rewrite_managed_block(ep, prompt)
112
+ return ep.relative_to(target_root).as_posix()
113
+
114
+ def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
115
+ _vac.finalize_codex_repo(target_root)
116
+
117
+
118
+ register(CodexAdapter())
119
+
120
+ __all__ = ["CodexAdapter"]
@@ -0,0 +1,122 @@
1
+ """Cursor IDE adapter: skills, MCP, agent ignore, AGENTS.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import warnings
7
+ from pathlib import Path
8
+
9
+ from spawn_cli.ide import _vacancy as _vac
10
+ from spawn_cli.ide.registry import (
11
+ DetectResult,
12
+ IdeAdapter,
13
+ IdeCapabilities,
14
+ register,
15
+ normalize_skill_name,
16
+ render_skill_md,
17
+ rewrite_managed_block,
18
+ rewrite_ignore_block,
19
+ remove_ignore_block,
20
+ )
21
+ from spawn_cli.models.mcp import McpServer, NormalizedMcp
22
+ from spawn_cli.models.skill import SkillMetadata
23
+
24
+
25
+ def _build_mcp_server_entry(server: McpServer) -> dict:
26
+ """Convert McpServer to .cursor/mcp.json entry (mcpServers format)."""
27
+ transport = server.transport
28
+ if transport.type == "stdio":
29
+ entry: dict = {"command": transport.command, "args": transport.args}
30
+ elif transport.type in ("streamable-http", "sse"):
31
+ entry = {"type": transport.type, "url": transport.url}
32
+ if transport.headers:
33
+ entry["headers"] = {k: f"${{{v}}}" for k, v in transport.headers.items()}
34
+ else:
35
+ entry = {"command": transport.command, "args": transport.args}
36
+ if server.env:
37
+ entry["env"] = {
38
+ k: f"${{{k}}}" if v.secret else v.value or f"${{{k}}}"
39
+ for k, v in server.env.items()
40
+ }
41
+ return entry
42
+
43
+
44
+ class CursorAdapter(IdeAdapter):
45
+ key = "cursor"
46
+
47
+ def detect(self, target_root: Path) -> DetectResult:
48
+ used = (target_root / ".cursor").exists()
49
+ return DetectResult(
50
+ used_in_repo=used,
51
+ capabilities=IdeCapabilities(
52
+ skills="native",
53
+ mcp="project",
54
+ agent_ignore="native",
55
+ entry_point="agents-md",
56
+ ),
57
+ )
58
+
59
+ def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
60
+ results = []
61
+ for skill in skill_metadata:
62
+ name = normalize_skill_name(skill.name)
63
+ skill_dir = target_root / ".cursor" / "skills" / name
64
+ skill_dir.mkdir(parents=True, exist_ok=True)
65
+ skill_path = skill_dir / "SKILL.md"
66
+ if skill_path.exists():
67
+ warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
68
+ skill_path.write_text(render_skill_md(skill), encoding="utf-8")
69
+ rel = skill_path.relative_to(target_root).as_posix()
70
+ results.append({"skill": skill.name, "path": rel})
71
+ return results
72
+
73
+ def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
74
+ for entry in rendered_paths:
75
+ p = target_root / entry["path"]
76
+ if p.exists():
77
+ p.unlink()
78
+ if p.parent.exists() and not any(p.parent.iterdir()):
79
+ p.parent.rmdir()
80
+
81
+ def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
82
+ """Merge Spawn-owned servers into .cursor/mcp.json (mcpServers format)."""
83
+ mcp_path = target_root / ".cursor" / "mcp.json"
84
+ mcp_path.parent.mkdir(parents=True, exist_ok=True)
85
+ data = json.loads(mcp_path.read_text(encoding="utf-8")) if mcp_path.exists() else {}
86
+ data.setdefault("mcpServers", {})
87
+ rendered_names = []
88
+ for server in normalized_mcp.servers:
89
+ entry = _build_mcp_server_entry(server)
90
+ data["mcpServers"][server.name] = entry
91
+ rendered_names.append(server.name)
92
+ mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
93
+ return rendered_names
94
+
95
+ def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
96
+ """Remove named entries from .cursor/mcp.json."""
97
+ mcp_path = target_root / ".cursor" / "mcp.json"
98
+ if not mcp_path.exists():
99
+ return
100
+ data = json.loads(mcp_path.read_text(encoding="utf-8"))
101
+ for name in rendered_mcp_names:
102
+ data.get("mcpServers", {}).pop(name, None)
103
+ mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
104
+
105
+ def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
106
+ rewrite_ignore_block(target_root / ".cursorignore", globs)
107
+
108
+ def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
109
+ remove_ignore_block(target_root / ".cursorignore", globs)
110
+
111
+ def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
112
+ ep = target_root / "AGENTS.md"
113
+ rewrite_managed_block(ep, prompt)
114
+ return str(ep.relative_to(target_root))
115
+
116
+ def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
117
+ _vac.finalize_standard_dotdir_skills_and_mcp(target_root, ".cursor", allow_delete_entire=True)
118
+
119
+
120
+ register(CursorAdapter())
121
+
122
+ __all__ = ["CursorAdapter"]